diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c8a45a7d..f20b87a0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -19,9 +19,6 @@ jobs: steps: - name: checkout uses: actions/checkout@v2 - - - name: force Xcode 13.4.1 - run: sudo xcode-select -switch /Applications/Xcode_13.4.1.app - name: setup env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1a3d7f70..eb5ca204 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,9 +28,6 @@ jobs: api-key-id: ${{ secrets.APPSTORE_KEY_ID }} api-private-key: ${{ secrets.APPSTORE_PRIVATE_KEY }} - - name: force Xcode 13.4.1 - run: sudo xcode-select -switch /Applications/Xcode_13.4.1.app - - name: setup env: AppSecret: ${{ secrets.AppSecret }} diff --git a/AppShared/AppShared.h b/AppShared/AppShared.h deleted file mode 100644 index a78d8be5..00000000 --- a/AppShared/AppShared.h +++ /dev/null @@ -1,19 +0,0 @@ -// -// AppShared.h -// AppShared -// -// Created by Cirno MainasuK on 2021-8-13. -// Copyright © 2021 Twidere. All rights reserved. -// - -#import - -//! Project version number for AppShared. -FOUNDATION_EXPORT double AppSharedVersionNumber; - -//! Project version string for AppShared. -FOUNDATION_EXPORT const unsigned char AppSharedVersionString[]; - -// In this header, you should import all the public headers of your framework using statements like #import - - diff --git a/AppShared/Info.plist b/AppShared/Info.plist deleted file mode 100644 index 3fd95d2a..00000000 --- a/AppShared/Info.plist +++ /dev/null @@ -1,22 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 1.4.2 - CFBundleVersion - 111 - - diff --git a/AppShared/Vender/DateTimeSwiftProvider.swift b/AppShared/Vender/DateTimeSwiftProvider.swift deleted file mode 100644 index 72a22e3e..00000000 --- a/AppShared/Vender/DateTimeSwiftProvider.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// DateTimeSwiftProvider.swift -// TwidereX -// -// Created by MainasuK on 2021/11/22. -// Copyright © 2021 Twidere. All rights reserved. -// - -import Foundation -import TwidereCore -import DateToolsSwift - -public class DateTimeSwiftProvider: DateTimeProvider { - public func shortTimeAgoSinceNow(to date: Date?) -> String? { - return date?.shortTimeAgoSinceNow - } - - public init() { } -} diff --git a/AppShared/Vender/OfficialTwitterTextProvider.swift b/AppShared/Vender/OfficialTwitterTextProvider.swift deleted file mode 100644 index fa004abd..00000000 --- a/AppShared/Vender/OfficialTwitterTextProvider.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// OfficialTwitterTextProvider.swift -// OfficialTwitterTextProvider -// -// Created by Cirno MainasuK on 2021-9-6. -// Copyright © 2021 Twidere. All rights reserved. -// - -import Foundation -import Meta -import TwitterMeta -import twitter_text - -public class OfficialTwitterTextProvider: TwitterTextProvider { - - public static let parser = TwitterTextParser.defaultParser() - - public func parse(text: String) -> ParseResult { - let result = OfficialTwitterTextProvider.parser.parseTweet(text) - - return ParseResult( - isValid: result.isValid, - weightedLength: result.weightedLength, - maxWeightedLength: OfficialTwitterTextProvider.parser.maxWeightedTweetLength(), - entities: self.entities(in: text) - ) - } - - - public func entities(in text: String) -> [TwitterTextProviderEntity] { - return TwitterText.entities(inText: text).compactMap { entity in - switch entity.type { - case .URL: return .url(range: entity.range) - case .screenName: return .screenName(range: entity.range) - case .hashtag: return .hashtag(range: entity.range) - case .listName: return .listName(range: entity.range) - case .symbol: return .symbol(range: entity.range) - case .tweetChar: return .tweetChar(range: entity.range) - case .tweetEmojiChar: return .tweetEmojiChar(range: entity.range) - @unknown default: - assertionFailure() - return nil - } - } - } - - public init() { } -} diff --git a/CoverFlowStackCollectionViewLayout/Example/Example/Base.lproj/Main.storyboard b/CoverFlowStackCollectionViewLayout/Example/Example/Base.lproj/Main.storyboard deleted file mode 100644 index 25a76385..00000000 --- a/CoverFlowStackCollectionViewLayout/Example/Example/Base.lproj/Main.storyboard +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/CoverFlowStackCollectionViewLayout/Tests/CoverFlowStackCollectionViewLayoutTests/CoverFlowStackCollectionViewLayoutTests.swift b/CoverFlowStackCollectionViewLayout/Tests/CoverFlowStackCollectionViewLayoutTests/CoverFlowStackCollectionViewLayoutTests.swift deleted file mode 100644 index 8e258121..00000000 --- a/CoverFlowStackCollectionViewLayout/Tests/CoverFlowStackCollectionViewLayoutTests/CoverFlowStackCollectionViewLayoutTests.swift +++ /dev/null @@ -1,11 +0,0 @@ -import XCTest -@testable import CoverFlowStackCollectionViewLayout - -final class CoverFlowStackCollectionViewLayoutTests: XCTestCase { - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. - XCTAssertEqual(CoverFlowStackCollectionViewLayout().text, "Hello, World!") - } -} diff --git a/CoverFlowStackCollectionViewLayout/Example/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/CoverFlowStackLayout/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata similarity index 100% rename from CoverFlowStackCollectionViewLayout/Example/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata rename to CoverFlowStackLayout/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata diff --git a/CoverFlowStackCollectionViewLayout/Example/Example.xcodeproj/project.pbxproj b/CoverFlowStackLayout/Example/Example.xcodeproj/project.pbxproj similarity index 89% rename from CoverFlowStackCollectionViewLayout/Example/Example.xcodeproj/project.pbxproj rename to CoverFlowStackLayout/Example/Example.xcodeproj/project.pbxproj index dc97f0b7..2497204a 100644 --- a/CoverFlowStackCollectionViewLayout/Example/Example.xcodeproj/project.pbxproj +++ b/CoverFlowStackLayout/Example/Example.xcodeproj/project.pbxproj @@ -9,23 +9,25 @@ /* Begin PBXBuildFile section */ DB33E59D27193C9600EC2225 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB33E59C27193C9600EC2225 /* AppDelegate.swift */; }; DB33E59F27193C9600EC2225 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB33E59E27193C9600EC2225 /* SceneDelegate.swift */; }; - DB33E5A127193C9600EC2225 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB33E5A027193C9600EC2225 /* ViewController.swift */; }; DB33E5A427193C9600EC2225 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = DB33E5A227193C9600EC2225 /* Main.storyboard */; }; DB33E5A627193C9900EC2225 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB33E5A527193C9900EC2225 /* Assets.xcassets */; }; DB33E5A927193C9900EC2225 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = DB33E5A727193C9900EC2225 /* LaunchScreen.storyboard */; }; - DB33E5B327193CEA00EC2225 /* CoverFlowStackCollectionViewLayout in Frameworks */ = {isa = PBXBuildFile; productRef = DB33E5B227193CEA00EC2225 /* CoverFlowStackCollectionViewLayout */; }; + DB3E0D9829ED51650077EE8B /* SwiftUIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3E0D9729ED51650077EE8B /* SwiftUIViewController.swift */; }; + DBE399DB29EFE448008FA278 /* CoverFlowStackLayout in Frameworks */ = {isa = PBXBuildFile; productRef = DBE399DA29EFE448008FA278 /* CoverFlowStackLayout */; }; + DBE3DC4429ED518F0054DC25 /* UIKitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3DC4329ED518F0054DC25 /* UIKitViewController.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ DB33E59927193C9600EC2225 /* Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Example.app; sourceTree = BUILT_PRODUCTS_DIR; }; DB33E59C27193C9600EC2225 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; DB33E59E27193C9600EC2225 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; - DB33E5A027193C9600EC2225 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; DB33E5A327193C9600EC2225 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; DB33E5A527193C9900EC2225 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; DB33E5A827193C9900EC2225 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; DB33E5AA27193C9900EC2225 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - DB33E5B027193CDB00EC2225 /* CoverFlowStackCollectionViewLayout */ = {isa = PBXFileReference; lastKnownFileType = folder; name = CoverFlowStackCollectionViewLayout; path = ..; sourceTree = ""; }; + DB3E0D9729ED51650077EE8B /* SwiftUIViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIViewController.swift; sourceTree = ""; }; + DBE399D929EFE429008FA278 /* CoverFlowStackLayout */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = CoverFlowStackLayout; path = ..; sourceTree = ""; }; + DBE3DC4329ED518F0054DC25 /* UIKitViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIKitViewController.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -33,7 +35,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - DB33E5B327193CEA00EC2225 /* CoverFlowStackCollectionViewLayout in Frameworks */, + DBE399DB29EFE448008FA278 /* CoverFlowStackLayout in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -43,7 +45,7 @@ DB33E59027193C9600EC2225 = { isa = PBXGroup; children = ( - DB33E5B027193CDB00EC2225 /* CoverFlowStackCollectionViewLayout */, + DBE399D929EFE429008FA278 /* CoverFlowStackLayout */, DB33E59B27193C9600EC2225 /* Example */, DB33E59A27193C9600EC2225 /* Products */, DB33E5B127193CEA00EC2225 /* Frameworks */, @@ -63,7 +65,8 @@ children = ( DB33E59C27193C9600EC2225 /* AppDelegate.swift */, DB33E59E27193C9600EC2225 /* SceneDelegate.swift */, - DB33E5A027193C9600EC2225 /* ViewController.swift */, + DBE3DC4329ED518F0054DC25 /* UIKitViewController.swift */, + DB3E0D9729ED51650077EE8B /* SwiftUIViewController.swift */, DB33E5A227193C9600EC2225 /* Main.storyboard */, DB33E5A527193C9900EC2225 /* Assets.xcassets */, DB33E5A727193C9900EC2225 /* LaunchScreen.storyboard */, @@ -96,7 +99,7 @@ ); name = Example; packageProductDependencies = ( - DB33E5B227193CEA00EC2225 /* CoverFlowStackCollectionViewLayout */, + DBE399DA29EFE448008FA278 /* CoverFlowStackLayout */, ); productName = Example; productReference = DB33E59927193C9600EC2225 /* Example.app */; @@ -153,7 +156,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - DB33E5A127193C9600EC2225 /* ViewController.swift in Sources */, + DB3E0D9829ED51650077EE8B /* SwiftUIViewController.swift in Sources */, + DBE3DC4429ED518F0054DC25 /* UIKitViewController.swift in Sources */, DB33E59D27193C9600EC2225 /* AppDelegate.swift in Sources */, DB33E59F27193C9600EC2225 /* SceneDelegate.swift in Sources */, ); @@ -377,9 +381,9 @@ /* End XCConfigurationList section */ /* Begin XCSwiftPackageProductDependency section */ - DB33E5B227193CEA00EC2225 /* CoverFlowStackCollectionViewLayout */ = { + DBE399DA29EFE448008FA278 /* CoverFlowStackLayout */ = { isa = XCSwiftPackageProductDependency; - productName = CoverFlowStackCollectionViewLayout; + productName = CoverFlowStackLayout; }; /* End XCSwiftPackageProductDependency section */ }; diff --git a/CoverFlowStackLayout/Example/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/CoverFlowStackLayout/Example/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/CoverFlowStackLayout/Example/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/CoverFlowStackCollectionViewLayout/Example/Example.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/CoverFlowStackLayout/Example/Example.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from CoverFlowStackCollectionViewLayout/Example/Example.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to CoverFlowStackLayout/Example/Example.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/CoverFlowStackCollectionViewLayout/Example/Example.xcodeproj/xcshareddata/xcschemes/Example - RTL.xcscheme b/CoverFlowStackLayout/Example/Example.xcodeproj/xcshareddata/xcschemes/Example - RTL.xcscheme similarity index 100% rename from CoverFlowStackCollectionViewLayout/Example/Example.xcodeproj/xcshareddata/xcschemes/Example - RTL.xcscheme rename to CoverFlowStackLayout/Example/Example.xcodeproj/xcshareddata/xcschemes/Example - RTL.xcscheme diff --git a/CoverFlowStackCollectionViewLayout/Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme b/CoverFlowStackLayout/Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme similarity index 100% rename from CoverFlowStackCollectionViewLayout/Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme rename to CoverFlowStackLayout/Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme diff --git a/CoverFlowStackCollectionViewLayout/Example/Example/AppDelegate.swift b/CoverFlowStackLayout/Example/Example/AppDelegate.swift similarity index 100% rename from CoverFlowStackCollectionViewLayout/Example/Example/AppDelegate.swift rename to CoverFlowStackLayout/Example/Example/AppDelegate.swift diff --git a/CoverFlowStackCollectionViewLayout/Example/Example/Assets.xcassets/AccentColor.colorset/Contents.json b/CoverFlowStackLayout/Example/Example/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from CoverFlowStackCollectionViewLayout/Example/Example/Assets.xcassets/AccentColor.colorset/Contents.json rename to CoverFlowStackLayout/Example/Example/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/CoverFlowStackCollectionViewLayout/Example/Example/Assets.xcassets/AppIcon.appiconset/Contents.json b/CoverFlowStackLayout/Example/Example/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from CoverFlowStackCollectionViewLayout/Example/Example/Assets.xcassets/AppIcon.appiconset/Contents.json rename to CoverFlowStackLayout/Example/Example/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/CoverFlowStackCollectionViewLayout/Example/Example/Assets.xcassets/Contents.json b/CoverFlowStackLayout/Example/Example/Assets.xcassets/Contents.json similarity index 100% rename from CoverFlowStackCollectionViewLayout/Example/Example/Assets.xcassets/Contents.json rename to CoverFlowStackLayout/Example/Example/Assets.xcassets/Contents.json diff --git a/CoverFlowStackCollectionViewLayout/Example/Example/Base.lproj/LaunchScreen.storyboard b/CoverFlowStackLayout/Example/Example/Base.lproj/LaunchScreen.storyboard similarity index 100% rename from CoverFlowStackCollectionViewLayout/Example/Example/Base.lproj/LaunchScreen.storyboard rename to CoverFlowStackLayout/Example/Example/Base.lproj/LaunchScreen.storyboard diff --git a/CoverFlowStackLayout/Example/Example/Base.lproj/Main.storyboard b/CoverFlowStackLayout/Example/Example/Base.lproj/Main.storyboard new file mode 100644 index 00000000..1c91519f --- /dev/null +++ b/CoverFlowStackLayout/Example/Example/Base.lproj/Main.storyboard @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CoverFlowStackCollectionViewLayout/Example/Example/Info.plist b/CoverFlowStackLayout/Example/Example/Info.plist similarity index 100% rename from CoverFlowStackCollectionViewLayout/Example/Example/Info.plist rename to CoverFlowStackLayout/Example/Example/Info.plist diff --git a/CoverFlowStackCollectionViewLayout/Example/Example/SceneDelegate.swift b/CoverFlowStackLayout/Example/Example/SceneDelegate.swift similarity index 100% rename from CoverFlowStackCollectionViewLayout/Example/Example/SceneDelegate.swift rename to CoverFlowStackLayout/Example/Example/SceneDelegate.swift diff --git a/CoverFlowStackLayout/Example/Example/SwiftUIViewController.swift b/CoverFlowStackLayout/Example/Example/SwiftUIViewController.swift new file mode 100644 index 00000000..674dbc0d --- /dev/null +++ b/CoverFlowStackLayout/Example/Example/SwiftUIViewController.swift @@ -0,0 +1,279 @@ +// +// SwiftUIViewController.swift +// Example +// +// Created by MainasuK on 2023/4/17. +// + +import UIKit +import SwiftUI +import CoverFlowStackScrollView + +final class SwiftUIViewController: UIViewController { + +} + +extension SwiftUIViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + title = "SwiftUI" + + let hostingController = UIHostingController(rootView: ContentView()) + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + hostingController.willMove(toParent: self) + view.addSubview(hostingController.view) + NSLayoutConstraint.activate([ + hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), + hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + } + +} + +public struct ContentView: View { + + @StateObject var viewModel = ViewModel() + + public var body: some View { + GeometryReader { root in + let dimension = min(root.size.width, root.size.height) + CoverFlowStackScrollView { + HStack(spacing: .zero) { + ForEach(Array(viewModel.colors.enumerated()), id: \.0) { index, color in + GeometryReader { geo in + let transformAttribute = viewModel.transformAttribute(at: index) + ZStack { + VStack { + Text("Number \(index)") + .font(.largeTitle) + Text("\(viewModel.frame(at: index).debugDescription)") + .font(.caption) + } + .frame( + width: transformAttribute.transformFrame.width, + height: transformAttribute.transformFrame.height + ) + .background(Color(uiColor: color)) + .offset( + x: transformAttribute.offsetX, + y: transformAttribute.offsetY + ) + } + .frame(width: dimension, height: dimension) + } + .frame(width: dimension, height: dimension) + .zIndex(Double(999 - index)) + } + } + } contentOffsetDidUpdate: { contentOffset in + viewModel.contentOffset = contentOffset + } contentSizeDidUpdate: { contentSize in + viewModel.contentSize = contentSize + } // end ScrollView + .overlay(alignment: .top) { + VStack { + Text("\(viewModel.progress)") + .font(.title) + Text("viewPort: \(viewModel.viewPortRect().debugDescription)") + .font(.caption) + Text("viewPort maxX: \(viewModel.viewPortRect().maxX)") + .font(.caption) + } + } + + } // end GeometryReader + } // end body + +} + +extension ContentView { + class ViewModel: ObservableObject { + + // input + @Published var colors: [UIColor] = [] + + @Published var contentOffset: CGFloat = .zero + @Published var contentSize: CGSize = .zero + + // output + var progress: CGFloat { + return abs(contentOffset) / contentSize.width + } + + init() { + colors = (0..<4).map { i in + return [.systemRed, .systemGreen, .systemBlue][i % 3] + } + } + } // end class +} + +extension ContentView.ViewModel { + func frame(at index: Int) -> CGRect { + let count = colors.count + guard count > 0 else { return .zero } + let width = contentSize.width / CGFloat(count) + let minX = CGFloat(index) * width + let frame = CGRect( + x: minX, + y: 0, + width: width, + height: contentSize.height + ) + return frame + } + + func viewPortRect() -> CGRect { + let count = colors.count + guard count > 0 else { return .zero } + let width = contentSize.width / CGFloat(count) + let rect = CGRect( + origin: .init(x: -contentOffset, y: 0), + size: .init(width: width, height: contentSize.height) + ) + return rect + } + + struct TransformAttribute { + + let originalFrame: CGRect + let transformFrame: CGRect + let zIndex: Int + let alpha: CGFloat + + init( + originalFrame: CGRect, + transformFrame: CGRect, + zIndex: Int, + alpha: CGFloat + ) { + self.originalFrame = originalFrame + self.transformFrame = transformFrame + self.zIndex = zIndex + self.alpha = alpha + } + + var offsetX: CGFloat { + return (transformFrame.minX - originalFrame.minX) + (transformFrame.width - originalFrame.width) / 2 + //return transformFrame.origin.x - originalFrame.origin.x + } + + var offsetY: CGFloat { + return .zero // (transformFrame.height - originalFrame.height) / 2 + //return transformFrame.origin.y - originalFrame.origin.y + } + } + + var sizeScaleRatio: CGFloat { 0.8 } + var trailingMarginRatio: CGFloat { 0.1 } + + func transformAttribute(at index: Int) -> TransformAttribute { + let originalFrame = frame(at: index) + let viewPortRect = self.viewPortRect() + + // calculate constants + let endFrameSize = CGSize( + width: viewPortRect.width * (1 - trailingMarginRatio), + height: viewPortRect.height + ) + let startFrameSize = CGSize( + width: endFrameSize.width * sizeScaleRatio, + height: endFrameSize.height * sizeScaleRatio + ) + + if originalFrame.minX <= viewPortRect.minX { + // A: top most cover + // set frame + return TransformAttribute( + originalFrame: originalFrame, + transformFrame: CGRect( + x: originalFrame.origin.x, + y: originalFrame.origin.y, + width: endFrameSize.width, + height: endFrameSize.height + ), + zIndex: Int.max - index, + alpha: 1 + ) + } else if originalFrame.minX <= viewPortRect.maxX { + // B: middle cover + // timing curve + let offset = viewPortRect.maxX - originalFrame.minX + let t = offset / viewPortRect.width + let timingCurve = easeInOutInterpolation(progress: t) + // get current scale ratio + let scaleRatio: CGFloat = { + let start = sizeScaleRatio + let end: CGFloat = 1 + return lerp(v0: start, v1: end, t: timingCurve) + }() + // set height + let height = endFrameSize.height * scaleRatio + // pin offsetY + let topMargin = (viewPortRect.height - height) / 2 + // set width + let width = endFrameSize.width * scaleRatio + // set offsetX + let end = viewPortRect.origin.x + let start = viewPortRect.maxX - width + let minX = lerp(v0: start, v1: end, t: timingCurve) + // set alpha + let alpha = lerp(v0: 0.5, v1: 1, t: timingCurve) + return TransformAttribute( + originalFrame: originalFrame, + transformFrame: CGRect( + x: minX, + y: topMargin - (originalFrame.height - endFrameSize.height) / 2, + width: width, + height: height + ), + zIndex: Int.max - index, + alpha: alpha + ) + } else { + // C: bottom cover + // timing curve + let offset = originalFrame.minX - viewPortRect.maxX + let t = 1 - (offset / viewPortRect.width) + // set height + let height = startFrameSize.height + // pin offsetY + let topMargin = (viewPortRect.height - height) / 2 + // set width + let width = startFrameSize.width + // set offsetX + let minX = viewPortRect.maxX - width + // set alpha + let alpha = lerp(v0: 0, v1: 0.5, t: t) + return TransformAttribute( + originalFrame: originalFrame, + transformFrame: CGRect( + x: minX, + y: topMargin, + width: width, + height: height + ), + zIndex: Int.max - index, + alpha: alpha + ) + } + } +} + +// ref: +// - https://stackoverflow.com/questions/13462001/ease-in-and-ease-out-animation-formula +// - https://math.stackexchange.com/questions/121720/ease-in-out-function/121755#121755 +// for a = 2 +func easeInOutInterpolation(progress t: CGFloat) -> CGFloat { + let sqt = t * t + return sqt / (2.0 * (sqt - t) + 1.0) +} + +// linear interpolation +func lerp(v0: CGFloat, v1: CGFloat, t: CGFloat) -> CGFloat { + return (1 - t) * v0 + (t * v1) +} diff --git a/CoverFlowStackCollectionViewLayout/Example/Example/ViewController.swift b/CoverFlowStackLayout/Example/Example/UIKitViewController.swift similarity index 92% rename from CoverFlowStackCollectionViewLayout/Example/Example/ViewController.swift rename to CoverFlowStackLayout/Example/Example/UIKitViewController.swift index 6fd750bc..1361e443 100644 --- a/CoverFlowStackCollectionViewLayout/Example/Example/ViewController.swift +++ b/CoverFlowStackLayout/Example/Example/UIKitViewController.swift @@ -1,5 +1,5 @@ // -// ViewController.swift +// UIKitViewController.swift // Example // // Created by Cirno MainasuK on 2021-10-15. @@ -8,7 +8,7 @@ import UIKit import CoverFlowStackCollectionViewLayout -class ViewController: UIViewController { +class UIKitViewController: UIViewController { var colors: [UIColor] = (0..<20).map { i in return [.systemRed, .systemGreen, .systemBlue][i % 3] @@ -26,6 +26,8 @@ class ViewController: UIViewController { super.viewDidLoad() // Do any additional setup after loading the view. + title = "UIKit" + collectionView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(collectionView) NSLayoutConstraint.activate([ @@ -64,7 +66,7 @@ extension CollectionViewCell { } // MARK: - UICollectionViewDataSource -extension ViewController: UICollectionViewDataSource { +extension UIKitViewController: UICollectionViewDataSource { func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return colors.count } diff --git a/CoverFlowStackCollectionViewLayout/Package.swift b/CoverFlowStackLayout/Package.swift similarity index 74% rename from CoverFlowStackCollectionViewLayout/Package.swift rename to CoverFlowStackLayout/Package.swift index 6a53d946..ca812cb3 100644 --- a/CoverFlowStackCollectionViewLayout/Package.swift +++ b/CoverFlowStackLayout/Package.swift @@ -4,13 +4,16 @@ import PackageDescription let package = Package( - name: "CoverFlowStackCollectionViewLayout", - platforms: [.iOS(.v10)], + name: "CoverFlowStackLayout", + platforms: [.iOS(.v13)], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. .library( - name: "CoverFlowStackCollectionViewLayout", - targets: ["CoverFlowStackCollectionViewLayout"]), + name: "CoverFlowStackLayout", + targets: [ + "CoverFlowStackCollectionViewLayout", + "CoverFlowStackScrollView", + ]), ], dependencies: [ // Dependencies declare other packages that this package depends on. @@ -22,6 +25,9 @@ let package = Package( .target( name: "CoverFlowStackCollectionViewLayout", dependencies: []), + .target( + name: "CoverFlowStackScrollView", + dependencies: []), .testTarget( name: "CoverFlowStackCollectionViewLayoutTests", dependencies: ["CoverFlowStackCollectionViewLayout"]), diff --git a/CoverFlowStackCollectionViewLayout/README.md b/CoverFlowStackLayout/README.md similarity index 100% rename from CoverFlowStackCollectionViewLayout/README.md rename to CoverFlowStackLayout/README.md diff --git a/CoverFlowStackCollectionViewLayout/Sources/CoverFlowStackCollectionViewLayout/CoverFlowStackCollectionViewLayout.swift b/CoverFlowStackLayout/Sources/CoverFlowStackCollectionViewLayout/CoverFlowStackCollectionViewLayout.swift similarity index 100% rename from CoverFlowStackCollectionViewLayout/Sources/CoverFlowStackCollectionViewLayout/CoverFlowStackCollectionViewLayout.swift rename to CoverFlowStackLayout/Sources/CoverFlowStackCollectionViewLayout/CoverFlowStackCollectionViewLayout.swift diff --git a/CoverFlowStackCollectionViewLayout/Sources/CoverFlowStackCollectionViewLayout/CoverFlowStackLayoutAttributes.swift b/CoverFlowStackLayout/Sources/CoverFlowStackCollectionViewLayout/CoverFlowStackLayoutAttributes.swift similarity index 100% rename from CoverFlowStackCollectionViewLayout/Sources/CoverFlowStackCollectionViewLayout/CoverFlowStackLayoutAttributes.swift rename to CoverFlowStackLayout/Sources/CoverFlowStackCollectionViewLayout/CoverFlowStackLayoutAttributes.swift diff --git a/CoverFlowStackLayout/Sources/CoverFlowStackScrollView/CoverFlowStackScrollView.swift b/CoverFlowStackLayout/Sources/CoverFlowStackScrollView/CoverFlowStackScrollView.swift new file mode 100644 index 00000000..1912928b --- /dev/null +++ b/CoverFlowStackLayout/Sources/CoverFlowStackScrollView/CoverFlowStackScrollView.swift @@ -0,0 +1,67 @@ +// +// CoverFlowStackScrollView.swift +// +// +// Created by MainasuK on 2023/4/17. +// + +import SwiftUI +import UIKit + + +// Seealso: Example.SwiftUIViewController +public struct CoverFlowStackScrollView: View { + + let id = UUID() + let content: () -> Content + let contentOffsetDidUpdate: (CGFloat) -> Void + let contentSizeDidUpdate: (CGSize) -> Void + + public init( + @ViewBuilder _ content: @escaping () -> Content, + contentOffsetDidUpdate: @escaping (CGFloat) -> Void, + contentSizeDidUpdate: @escaping (CGSize) -> Void + ) { + self.content = content + self.contentOffsetDidUpdate = contentOffsetDidUpdate + self.contentSizeDidUpdate = contentSizeDidUpdate + } + + public var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + offsetReader + content() + .background(GeometryReader{ proxy in + Color.clear.preference(key: SizePreferenceKey.self, value: proxy.size) + .onPreferenceChange(SizePreferenceKey.self) { size in + contentSizeDidUpdate(size) + } + }) + } + .coordinateSpace(name: id.uuidString) + .onPreferenceChange(OffsetPreferenceKey.self) { offset in + contentOffsetDidUpdate(offset) + } + } + + var offsetReader: some View { + GeometryReader { proxy in + Color.clear + .preference( + key: OffsetPreferenceKey.self, + value: proxy.frame(in: .named(id.uuidString)).minX + ) + } + .frame(height: .leastNonzeroMagnitude) + } +} + +private struct OffsetPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = .zero + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { } +} + +private struct SizePreferenceKey: PreferenceKey { + static var defaultValue: CGSize = .zero + static func reduce(value: inout CGSize, nextValue: () -> CGSize) { } +} diff --git a/CoverFlowStackLayout/Tests/CoverFlowStackCollectionViewLayoutTests/CoverFlowStackCollectionViewLayoutTests.swift b/CoverFlowStackLayout/Tests/CoverFlowStackCollectionViewLayoutTests/CoverFlowStackCollectionViewLayoutTests.swift new file mode 100644 index 00000000..96aa1764 --- /dev/null +++ b/CoverFlowStackLayout/Tests/CoverFlowStackCollectionViewLayoutTests/CoverFlowStackCollectionViewLayoutTests.swift @@ -0,0 +1,7 @@ +import XCTest +@testable import CoverFlowStackCollectionViewLayout + +final class CoverFlowStackCollectionViewLayoutTests: XCTestCase { + func testSmoke() throws { + } +} diff --git a/Localization/StringsConvertor/Sources/StringsConvertor/main.swift b/Localization/StringsConvertor/Sources/StringsConvertor/main.swift index 8001b768..c0042445 100644 --- a/Localization/StringsConvertor/Sources/StringsConvertor/main.swift +++ b/Localization/StringsConvertor/Sources/StringsConvertor/main.swift @@ -52,19 +52,20 @@ class Helper { private func map(language: String) -> String? { switch language { - case "ar_SA": return "ar" // Arabic - case "eu_ES": return "eu" // Basque - case "en_US": return "en" - case "zh_CN": return "zh-Hans" // Chinese Simplified - case "ja_JP": return "ja" // Japanese - case "gl_ES": return "gl" // Galician - case "de_DE": return "de" // German - case "pt_BR": return "pt-BR" // Brazilian Portuguese - case "ca_ES": return "ca" // Catalan - case "es_ES": return "es" // Spanish - case "ko_KR": return "ko" // Korean - case "tr_TR": return "tr" // Turkish - default: return nil + case "Base.lproj": return "Base" + case "ar_SA": return "ar" // Arabic + case "eu_ES": return "eu" // Basque + case "en_US": return "en" + case "zh_CN": return "zh-Hans" // Chinese Simplified + case "ja_JP": return "ja" // Japanese + case "gl_ES": return "gl" // Galician + case "de_DE": return "de" // German + case "pt_BR": return "pt-BR" // Brazilian Portuguese + case "ca_ES": return "ca" // Catalan + case "es_ES": return "es" // Spanish + case "ko_KR": return "ko" // Korean + case "tr_TR": return "tr" // Turkish + default: return nil } } diff --git a/NotificationService/Info.plist b/NotificationService/Info.plist index 8fea3554..2d70cada 100644 --- a/NotificationService/Info.plist +++ b/NotificationService/Info.plist @@ -3,7 +3,7 @@ CFBundleShortVersionString - 1.4.2 + 2.0.1 CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -19,7 +19,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleVersion - 117 + 140 NSExtension NSExtensionPointIdentifier diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift index 5fce6ce7..b53ad8ec 100644 --- a/NotificationService/NotificationService.swift +++ b/NotificationService/NotificationService.swift @@ -9,7 +9,6 @@ import os.log import UIKit import UserNotifications -import AppShared import TwidereCommon import TwidereCore import AlamofireImage diff --git a/Podfile b/Podfile index 65025ada..a8b1f03e 100644 --- a/Podfile +++ b/Podfile @@ -1,19 +1,9 @@ source 'https://cdn.cocoapods.org/' platform :ios, '15.0' -def common_pods - # Misc - pod 'DateToolsSwift', '~> 5.0.0' - # Twitter - pod 'twitter-text', '~> 3.1.0' -end - target 'TwidereX' do # Comment the next line if you don't want to use dynamic frameworks use_frameworks! - - # Pods for TwidereX - common_pods ## UI pod 'XLPagerTabStrip', '~> 9.0.0' @@ -25,12 +15,8 @@ target 'TwidereX' do pod 'FirebaseMessaging' # misc - pod 'SwiftGen', '~> 6.5.1' + pod 'SwiftGen', '~> 6.6.2' pod 'Sourcery', '~> 1.8.1' - - # Debug - pod 'FLEX', '~> 4.7.0', :configurations => ['Debug'] - pod 'ZIPFoundation', '~> 0.9.11', :configurations => ['Debug'] target 'TwidereXTests' do inherit! :search_paths @@ -43,12 +29,6 @@ target 'TwidereX' do end -target 'AppShared' do - # Comment the next line if you don't want to use dynamic frameworks - use_frameworks! - common_pods -end - post_install do |installer| installer.pods_project.targets.each do |target| target.build_configurations.each do |config| diff --git a/Podfile.lock b/Podfile.lock index 43dbbccd..f7ddadb7 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -1,5 +1,4 @@ PODS: - - DateToolsSwift (5.0.0) - Firebase/AnalyticsWithoutAdIdSupport (9.2.0): - Firebase/CoreOnly - FirebaseAnalytics/WithoutAdIdSupport (~> 9.2.0) @@ -64,7 +63,6 @@ PODS: - FirebaseInstallations (~> 9.0) - GoogleUtilities/Environment (~> 7.7) - "GoogleUtilities/NSData+zlib (~> 7.7)" - - FLEX (4.7.0) - GoogleAppMeasurement/WithoutAdIdSupport (9.2.0): - GoogleUtilities/AppDelegateSwizzler (~> 7.7) - GoogleUtilities/MethodSwizzler (~> 7.7) @@ -104,27 +102,20 @@ PODS: - Sourcery (1.8.1): - Sourcery/CLI-Only (= 1.8.1) - Sourcery/CLI-Only (1.8.1) - - SwiftGen (6.5.1) - - twitter-text (3.1.0) + - SwiftGen (6.6.2) - XLPagerTabStrip (9.0.0) - - ZIPFoundation (0.9.13) DEPENDENCIES: - - DateToolsSwift (~> 5.0.0) - Firebase/AnalyticsWithoutAdIdSupport - FirebaseCrashlytics - FirebaseMessaging - FirebasePerformance - - FLEX (~> 4.7.0) - Sourcery (~> 1.8.1) - - SwiftGen (~> 6.5.1) - - twitter-text (~> 3.1.0) + - SwiftGen (~> 6.6.2) - XLPagerTabStrip (~> 9.0.0) - - ZIPFoundation (~> 0.9.11) SPEC REPOS: trunk: - - DateToolsSwift - Firebase - FirebaseABTesting - FirebaseAnalytics @@ -136,7 +127,6 @@ SPEC REPOS: - FirebaseMessaging - FirebasePerformance - FirebaseRemoteConfig - - FLEX - GoogleAppMeasurement - GoogleDataTransport - GoogleUtilities @@ -144,12 +134,9 @@ SPEC REPOS: - PromisesObjC - Sourcery - SwiftGen - - twitter-text - XLPagerTabStrip - - ZIPFoundation SPEC CHECKSUMS: - DateToolsSwift: 4207ada6ad615d8dc076323d27037c94916dbfa6 Firebase: 4ba896cb8e5105d4b9e247e1c1b6222b548df55a FirebaseABTesting: cd1ec762a0078b46a7ce91dfe5b7b8991c2dff8f FirebaseAnalytics: af5a03a8dff7648c7b8486f6a78b1368e0268dd3 @@ -161,18 +148,15 @@ SPEC CHECKSUMS: FirebaseMessaging: 4eaf1b8a7464b2c5e619ad66e9b20ee3e3206b24 FirebasePerformance: 5a8d2a9e645a398dfcc02657853f4b946675d5d4 FirebaseRemoteConfig: 16e29297f0dd0c7d2415c4506d614fe0b54875d1 - FLEX: bdc9ac7d4a239e3d04c298c01221203257d63a80 GoogleAppMeasurement: 7a33224321f975d58c166657260526775d9c6b1a GoogleDataTransport: 5fffe35792f8b96ec8d6775f5eccd83c998d5a3b GoogleUtilities: e0913149f6b0625b553d70dae12b49fc62914fd1 nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431 PromisesObjC: ab77feca74fa2823e7af4249b8326368e61014cb Sourcery: 4d44d4ea26a682a4a9875ec7c1870a1e7b8e183f - SwiftGen: a6d22010845f08fe18fbdf3a07a8e380fd22e0ea - twitter-text: 3a0d73ca52955439dc8b208ca7e123ea0abd6a51 + SwiftGen: 1366a7f71aeef49954ca5a63ba4bef6b0f24138c XLPagerTabStrip: 61c57fd61f611ee5f01ff1495ad6fbee8bf496c5 - ZIPFoundation: ae5b4b813d216d3bf0a148773267fff14bd51d37 -PODFILE CHECKSUM: 9a116be6704a7b6b4940f5dfb93bf4c4214dbc93 +PODFILE CHECKSUM: e2c773b0e4d6bfd3166d7794b7f8babe8f9b9b92 -COCOAPODS: 1.11.3 +COCOAPODS: 1.12.1 diff --git a/ShareExtension/ComposeViewController.swift b/ShareExtension/ComposeViewController.swift index ae692b16..71f41720 100644 --- a/ShareExtension/ComposeViewController.swift +++ b/ShareExtension/ComposeViewController.swift @@ -9,10 +9,11 @@ import os.log import UIKit import Combine -import AppShared -import TwidereUI +import CoreDataStack import UniformTypeIdentifiers +@_exported import TwidereUI + class ComposeViewController: UIViewController { let logger = Logger(subsystem: "ComposeViewController", category: "ViewController") @@ -25,26 +26,29 @@ class ComposeViewController: UIViewController { private(set) lazy var sendBarButtonItem = UIBarButtonItem(image: Asset.Transportation.paperAirplane.image, style: .plain, target: self, action: #selector(ComposeViewController.sendBarButtonItemPressed(_:))) - lazy var composeContentViewModel: ComposeContentViewModel = { - return ComposeContentViewModel( - kind: .post, - configurationContext: ComposeContentViewModel.ConfigurationContext( - apiService: context.apiService, - authenticationService: context.authenticationService, - mastodonEmojiService: context.mastodonEmojiService, - statusViewConfigureContext: .init( - dateTimeProvider: DateTimeSwiftProvider(), - twitterTextProvider: OfficialTwitterTextProvider(), - authenticationContext: context.authenticationService.$activeAuthenticationContext - ) - ) - ) - }() - private(set) lazy var composeContentViewController: ComposeContentViewController = { - let composeContentViewController = ComposeContentViewController() - composeContentViewController.viewModel = composeContentViewModel - return composeContentViewController - }() + private var composeContentViewModel: ComposeContentViewModel? + private var composeContentViewController: ComposeContentViewController? + +// lazy var composeContentViewModel: ComposeContentViewModel = { +// return ComposeContentViewModel( +// kind: .post, +// configurationContext: ComposeContentViewModel.ConfigurationContext( +// apiService: context.apiService, +// authenticationService: context.authenticationService, +// mastodonEmojiService: context.mastodonEmojiService, +// statusViewConfigureContext: .init( +// dateTimeProvider: DateTimeSwiftProvider(), +// twitterTextProvider: OfficialTwitterTextProvider(), +// authenticationContext: context.authenticationService.$activeAuthenticationContext +// ) +// ) +// ) +// }() +// private(set) lazy var composeContentViewController: ComposeContentViewController = { +// let composeContentViewController = ComposeContentViewController() +// composeContentViewController.viewModel = composeContentViewModel +// return composeContentViewController +// }() let activityIndicatorBarButtonItem: UIBarButtonItem = { let indicatorView = UIActivityIndicatorView(style: .medium) @@ -75,6 +79,7 @@ extension ComposeViewController { navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .close, target: self, action: #selector(ComposeViewController.closeBarButtonItemPressed(_:))) navigationItem.rightBarButtonItem = sendBarButtonItem + viewModel.$isBusy .receive(on: DispatchQueue.main) .sink { [weak self] isBusy in @@ -88,59 +93,92 @@ extension ComposeViewController { await load(inputItems: inputItems) } // end Task - addChild(composeContentViewController) - composeContentViewController.view.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(composeContentViewController.view) - NSLayoutConstraint.activate([ - composeContentViewController.view.topAnchor.constraint(equalTo: view.topAnchor), - composeContentViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), - composeContentViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), - composeContentViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ]) - composeContentViewController.didMove(toParent: self) - - // layout publish progress - publishProgressView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(publishProgressView) - NSLayoutConstraint.activate([ - publishProgressView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor), - publishProgressView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - publishProgressView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - ]) - - // bind compose bar button item - composeContentViewModel.$isComposeBarButtonEnabled - .receive(on: DispatchQueue.main) - .assign(to: \.isEnabled, on: sendBarButtonItem) - .store(in: &disposeBag) - - // bind author - viewModel.$author.assign(to: &composeContentViewModel.$author) - - // bind progress bar - viewModel.$currentPublishProgress - .receive(on: DispatchQueue.main) - .sink { [weak self] progress in - guard let self = self else { return } - let progress = Float(progress) - let withAnimation = progress > self.publishProgressView.progress - self.publishProgressView.setProgress(progress, animated: withAnimation) - - if progress == 1 { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in - guard let self = self else { return } - self.publishProgressView.setProgress(0, animated: false) + do { + guard let authContext = try setupAuthContext() else { + // setupHintLabel() + return + } + viewModel.authContext = authContext + + let composeContentViewModel = ComposeContentViewModel( + context: context, + authContext: authContext, + kind: .post + ) + let composeContentViewController = ComposeContentViewController() + composeContentViewController.viewModel = composeContentViewModel + + addChild(composeContentViewController) + composeContentViewController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(composeContentViewController.view) + NSLayoutConstraint.activate([ + composeContentViewController.view.topAnchor.constraint(equalTo: view.topAnchor), + composeContentViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + composeContentViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + composeContentViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + composeContentViewController.didMove(toParent: self) + + // layout publish progress + publishProgressView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(publishProgressView) + NSLayoutConstraint.activate([ + publishProgressView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor), + publishProgressView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + publishProgressView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + + // bind compose bar button item + composeContentViewModel.$isComposeBarButtonEnabled + .receive(on: DispatchQueue.main) + .assign(to: \.isEnabled, on: sendBarButtonItem) + .store(in: &disposeBag) + + // bind progress bar + viewModel.$currentPublishProgress + .receive(on: DispatchQueue.main) + .sink { [weak self] progress in + guard let self = self else { return } + let progress = Float(progress) + let withAnimation = progress > self.publishProgressView.progress + self.publishProgressView.setProgress(progress, animated: withAnimation) + + if progress == 1 { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in + guard let self = self else { return } + self.publishProgressView.setProgress(0, animated: false) + } } } - } - .store(in: &disposeBag) - - // set delegate - composeContentViewController.delegate = self + .store(in: &disposeBag) + + // set delegate + composeContentViewController.delegate = self + + self.composeContentViewModel = composeContentViewModel + self.composeContentViewController = composeContentViewController + + Task { @MainActor in + let inputItems = self.extensionContext?.inputItems.compactMap { $0 as? NSExtensionItem } ?? [] + await load(inputItems: inputItems) + } // end Task + + } catch { + + } } } +extension ComposeViewController { + private func setupAuthContext() throws -> AuthContext? { + let request = AuthenticationIndex.sortedFetchRequest + let _authenticationIndex = try context.managedObjectContext.fetch(request).first + let _authContext = _authenticationIndex.flatMap { AuthContext(authenticationIndex: $0) } + return _authContext + } +} + extension ComposeViewController { @objc private func closeBarButtonItemPressed(_ sender: UIBarButtonItem) { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") @@ -154,7 +192,10 @@ extension ComposeViewController { Task { @MainActor in do { await self.setBusy(true) - let statusPublisher = try composeContentViewModel.statusPublisher() + guard let statusPublisher = try self.composeContentViewModel?.statusPublisher() else { + await self.setBusy(false) + return + } // setup progress self.viewModel.currentPublishProgressObservation = statusPublisher.progress @@ -195,6 +236,13 @@ extension ComposeViewController { extension ComposeViewController { private func load(inputItems: [NSExtensionItem]) async { + guard let composeContentViewModel = self.composeContentViewModel, + let authContext = viewModel.authContext + else { + assertionFailure() + return + } + var itemProviders: [NSItemProvider] = [] for item in inputItems { @@ -281,7 +329,7 @@ extension ComposeViewController: ComposeContentViewControllerDelegate { extension ComposeViewController: UIAdaptivePresentationControllerDelegate { func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { - return composeContentViewModel.canDismissDirectly + return composeContentViewModel?.canDismissDirectly ?? true } func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) { diff --git a/ShareExtension/ComposeViewModel.swift b/ShareExtension/ComposeViewModel.swift index 840ff5e9..1f961f8e 100644 --- a/ShareExtension/ComposeViewModel.swift +++ b/ShareExtension/ComposeViewModel.swift @@ -14,28 +14,22 @@ final class ComposeViewModel { var disposeBag = Set() + var currentPublishProgressObservation: NSKeyValueObservation? + // input let context: AppContext + @Published public var viewLayoutFrame = ViewLayoutFrame() + + @Published var authContext: AuthContext? @Published var isBusy = false @Published var didLoad = false - var currentPublishProgressObservation: NSKeyValueObservation? - // output - @Published var author: UserObject? @Published var currentPublishProgress: Double = 0 init(context: AppContext) { self.context = context // end init - - context.authenticationService.activeAuthenticationIndex - .receive(on: DispatchQueue.main) - .sink { [weak self] authenticationIndex in - guard let self = self else { return } - self.author = authenticationIndex?.user - } - .store(in: &disposeBag) } } diff --git a/ShareExtension/Info.plist b/ShareExtension/Info.plist index c02f1cde..cfca8ab8 100644 --- a/ShareExtension/Info.plist +++ b/ShareExtension/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.4.2 + 2.0.1 CFBundleVersion - 117 + 140 NSExtension NSExtensionAttributes diff --git a/StubMixer/AppDelegate.swift b/StubMixer/AppDelegate.swift deleted file mode 100644 index 1f5fa4f8..00000000 --- a/StubMixer/AppDelegate.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// AppDelegate.swift -// StubMixer -// -// Created by Cirno MainasuK on 2020-9-3. -// - -import UIKit - -@main -class AppDelegate: UIResponder, UIApplicationDelegate { - - - - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - return true - } - - // MARK: UISceneSession Lifecycle - - func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { - // Called when a new scene session is being created. - // Use this method to select a configuration to create the new scene with. - return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) - } - - func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { - // Called when the user discards a scene session. - // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. - // Use this method to release any resources that were specific to the discarded scenes, as they will not return. - } - - -} - diff --git a/StubMixer/Assets.xcassets/AccentColor.colorset/Contents.json b/StubMixer/Assets.xcassets/AccentColor.colorset/Contents.json deleted file mode 100644 index eb878970..00000000 --- a/StubMixer/Assets.xcassets/AccentColor.colorset/Contents.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "colors" : [ - { - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/StubMixer/Assets.xcassets/AppIcon.appiconset/Contents.json b/StubMixer/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 9221b9bb..00000000 --- a/StubMixer/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,98 +0,0 @@ -{ - "images" : [ - { - "idiom" : "iphone", - "scale" : "2x", - "size" : "20x20" - }, - { - "idiom" : "iphone", - "scale" : "3x", - "size" : "20x20" - }, - { - "idiom" : "iphone", - "scale" : "2x", - "size" : "29x29" - }, - { - "idiom" : "iphone", - "scale" : "3x", - "size" : "29x29" - }, - { - "idiom" : "iphone", - "scale" : "2x", - "size" : "40x40" - }, - { - "idiom" : "iphone", - "scale" : "3x", - "size" : "40x40" - }, - { - "idiom" : "iphone", - "scale" : "2x", - "size" : "60x60" - }, - { - "idiom" : "iphone", - "scale" : "3x", - "size" : "60x60" - }, - { - "idiom" : "ipad", - "scale" : "1x", - "size" : "20x20" - }, - { - "idiom" : "ipad", - "scale" : "2x", - "size" : "20x20" - }, - { - "idiom" : "ipad", - "scale" : "1x", - "size" : "29x29" - }, - { - "idiom" : "ipad", - "scale" : "2x", - "size" : "29x29" - }, - { - "idiom" : "ipad", - "scale" : "1x", - "size" : "40x40" - }, - { - "idiom" : "ipad", - "scale" : "2x", - "size" : "40x40" - }, - { - "idiom" : "ipad", - "scale" : "1x", - "size" : "76x76" - }, - { - "idiom" : "ipad", - "scale" : "2x", - "size" : "76x76" - }, - { - "idiom" : "ipad", - "scale" : "2x", - "size" : "83.5x83.5" - }, - { - "idiom" : "ios-marketing", - "scale" : "1x", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/StubMixer/Assets.xcassets/Contents.json b/StubMixer/Assets.xcassets/Contents.json deleted file mode 100644 index 73c00596..00000000 --- a/StubMixer/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/StubMixer/Base.lproj/LaunchScreen.storyboard b/StubMixer/Base.lproj/LaunchScreen.storyboard deleted file mode 100644 index 865e9329..00000000 --- a/StubMixer/Base.lproj/LaunchScreen.storyboard +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/StubMixer/Base.lproj/Main.storyboard b/StubMixer/Base.lproj/Main.storyboard deleted file mode 100644 index e95b2d40..00000000 --- a/StubMixer/Base.lproj/Main.storyboard +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/StubMixer/Info.plist b/StubMixer/Info.plist deleted file mode 100644 index 6f183d94..00000000 --- a/StubMixer/Info.plist +++ /dev/null @@ -1,66 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 1.2.0 - CFBundleVersion - $(CURRENT_PROJECT_VERSION) - LSRequiresIPhoneOS - - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneConfigurationName - Default Configuration - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).SceneDelegate - UISceneStoryboardFile - Main - - - - - UIApplicationSupportsIndirectInputEvents - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UIRequiredDeviceCapabilities - - armv7 - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - - diff --git a/StubMixer/SceneDelegate.swift b/StubMixer/SceneDelegate.swift deleted file mode 100644 index 313d6c9d..00000000 --- a/StubMixer/SceneDelegate.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// SceneDelegate.swift -// StubMixer -// -// Created by Cirno MainasuK on 2020-9-3. -// - -import UIKit - -class SceneDelegate: UIResponder, UIWindowSceneDelegate { - - var window: UIWindow? - - func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { - // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. - // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. - // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). - guard let _ = (scene as? UIWindowScene) else { return } - } - - func sceneDidDisconnect(_ scene: UIScene) { - // Called as the scene is being released by the system. - // This occurs shortly after the scene enters the background, or when its session is discarded. - // Release any resources associated with this scene that can be re-created the next time the scene connects. - // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). - } - - func sceneDidBecomeActive(_ scene: UIScene) { - // Called when the scene has moved from an inactive state to an active state. - // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. - } - - func sceneWillResignActive(_ scene: UIScene) { - // Called when the scene will move from an active state to an inactive state. - // This may occur due to temporary interruptions (ex. an incoming phone call). - } - - func sceneWillEnterForeground(_ scene: UIScene) { - // Called as the scene transitions from the background to the foreground. - // Use this method to undo the changes made on entering the background. - } - - func sceneDidEnterBackground(_ scene: UIScene) { - // Called as the scene transitions from the foreground to the background. - // Use this method to save data, release shared resources, and store enough scene-specific state information - // to restore the scene back to its current state. - } - - -} - -#if DEBUG -class TestWindow: UIWindow { - - override func sendEvent(_ event: UIEvent) { - event.allTouches?.forEach({ (touch) in - let location = touch.location(in: self) - let view = hitTest(location, with: event) - print(view) - }) - - super.sendEvent(event) - } -} -#endif diff --git a/StubMixer/StubMixer.swift b/StubMixer/StubMixer.swift deleted file mode 100644 index d4f2a9f4..00000000 --- a/StubMixer/StubMixer.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// StubMixer.swift -// StubMixer -// -// Created by Cirno MainasuK on 2020-9-3. -// - -import Foundation -import CryptoKit - -public enum StubMixer { - public static func mix(message: Data, use key: String, nonce: String) -> Data { - var sha256 = SHA256() - sha256.update(data: Data(key.utf8)) - sha256.update(data: Data(nonce.utf8)) - let keyDigest = sha256.finalize() - let symmetricKey = SymmetricKey(data: keyDigest) - - let sealedBox = try! AES.GCM.seal(message, using: symmetricKey) - return sealedBox.combined! - } - - public static func restore(combined: Data, use key: String, nonce: String) -> Data { - var sha256 = SHA256() - sha256.update(data: Data(key.utf8)) - sha256.update(data: Data(nonce.utf8)) - let keyDigest = sha256.finalize() - let symmetricKey = SymmetricKey(data: keyDigest) - - let sealedBox = try! AES.GCM.SealedBox(combined: combined) - let message = try! AES.GCM.open(sealedBox, using: symmetricKey) - return message - } -} diff --git a/StubMixer/ViewController.swift b/StubMixer/ViewController.swift deleted file mode 100644 index 7178a025..00000000 --- a/StubMixer/ViewController.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// ViewController.swift -// StubMixer -// -// Created by Cirno MainasuK on 2020-9-3. -// - -import UIKit - -class ViewController: UIViewController { - - override func viewDidLoad() { - super.viewDidLoad() - // Do any additional setup after loading the view. - } - - -} - diff --git a/TwidereSDK/.swiftpm/xcode/xcshareddata/xcschemes/TwidereSDK.xcscheme b/TwidereSDK/.swiftpm/xcode/xcshareddata/xcschemes/TwidereSDK.xcscheme new file mode 100644 index 00000000..cbe2a11a --- /dev/null +++ b/TwidereSDK/.swiftpm/xcode/xcshareddata/xcschemes/TwidereSDK.xcscheme @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TwidereSDK/Package.swift b/TwidereSDK/Package.swift index 726e31b5..2d6228b1 100644 --- a/TwidereSDK/Package.swift +++ b/TwidereSDK/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.6 +// swift-tools-version:5.7 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -7,14 +7,13 @@ let package = Package( name: "TwidereSDK", defaultLocalization: "en", platforms: [ - .iOS(.v15), + .iOS(.v16), .macOS(.v12), ], products: [ .library( name: "TwidereSDK", targets: [ - "TwitterSDK", "MastodonSDK", "CoreDataStack", "TwidereAsset", @@ -30,7 +29,7 @@ let package = Package( .package(url: "https://github.com/apple/swift-nio.git", from: "2.34.0"), .package(url: "https://github.com/Flipboard/FLAnimatedImage.git", from: "1.0.0"), .package(url: "https://github.com/MainasuK/CommonOSLog", from: "0.1.1"), - .package(url: "https://github.com/TwidereProject/MetaTextKit.git", .exact("3.3.2")), + .package(url: "https://github.com/TwidereProject/MetaTextKit.git", exact: "4.6.0"), .package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.5.0"), .package(url: "https://github.com/Alamofire/AlamofireImage.git", from: "4.1.0"), .package(url: "https://github.com/Alamofire/AlamofireNetworkActivityIndicator.git", from: "3.1.0"), @@ -43,8 +42,17 @@ let package = Package( .package(url: "https://github.com/SwiftKickMobile/SwiftMessages.git", from: "9.0.5"), .package(url: "https://github.com/aheze/Popovers.git", from: "1.3.2"), .package(url: "https://github.com/kishikawakatsumi/KeychainAccess.git", from: "4.2.2"), - .package(name: "Introspect", url: "https://github.com/siteline/SwiftUI-Introspect.git", from: "0.1.4"), + .package(url: "https://github.com/siteline/SwiftUI-Introspect.git", from: "0.1.4"), + .package(url: "https://github.com/MainasuK/DateTools", branch: "master"), + .package(url: "https://github.com/kciter/Floaty.git", branch: "master"), + .package(url: "https://github.com/MainasuK/FPSIndicator.git", from: "1.1.0"), + .package(url: "https://github.com/uias/Tabman.git", from: "3.0.1"), + .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.17.0"), .package(name: "ArkanaKeys", path: "../dependencies/ArkanaKeys"), + .package(name: "CoverFlowStackLayout", path: "../CoverFlowStackLayout"), ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. @@ -58,20 +66,6 @@ let package = Package( "Template/Stencil" ] ), - .target( - name: "TwitterSDK", - dependencies: [ - .product(name: "SwiftyJSON", package: "SwiftyJSON"), - .product(name: "NIOHTTP1", package: "swift-nio"), - ] - ), - .testTarget( - name: "TwitterSDKTests", - dependencies: [ - "TwitterSDK", - .product(name: "CommonOSLog", package: "CommonOSLog"), - ] - ), .target( name: "MastodonSDK", dependencies: [ @@ -87,10 +81,15 @@ let package = Package( .target( name: "TwidereCommon", dependencies: [ - "TwitterSDK", "MastodonSDK", .product(name: "KeychainAccess", package: "KeychainAccess"), .product(name: "ArkanaKeys", package: "ArkanaKeys"), + .product(name: "TwitterSDK", package: "TwitterSDK"), + ], + exclude: [ + "Template/AutoGenerateProtocolDelegate.swifttemplate", + "Template/AutoGenerateProtocolRelayDelegate.swifttemplate", + "Template/AutoGenerateTableViewDelegate.stencil", ] ), .target( @@ -99,7 +98,6 @@ let package = Package( "TwidereAsset", "TwidereCommon", "TwidereLocalization", - "TwitterSDK", "MastodonSDK", "CoreDataStack", .product(name: "CommonOSLog", package: "CommonOSLog"), @@ -107,6 +105,11 @@ let package = Package( .product(name: "AlamofireImage", package: "AlamofireImage"), .product(name: "AlamofireNetworkActivityIndicator", package: "AlamofireNetworkActivityIndicator"), .product(name: "MetaTextKit", package: "MetaTextKit"), + .product(name: "DateToolsSwift", package: "DateTools"), + .product(name: "CryptoSwift", package: "CryptoSwift"), + .product(name: "Kanna", package: "Kanna"), + .product(name: "TwitterSDK", package: "TwitterSDK"), + .product(name: "CollectionConcurrencyKit", package: "CollectionConcurrencyKit"), ] ), .target( @@ -119,13 +122,17 @@ let package = Package( "TwidereCore", .product(name: "CropViewController", package: "TOCropViewController"), .product(name: "FLAnimatedImage", package: "FLAnimatedImage"), - .product(name: "Introspect", package: "Introspect"), + .product(name: "Introspect", package: "SwiftUI-Introspect"), .product(name: "KeyboardLayoutGuide", package: "KeyboardLayoutGuide"), .product(name: "Kingfisher", package: "Kingfisher"), .product(name: "Popovers", package: "Popovers"), .product(name: "SDWebImage", package: "SDWebImage"), .product(name: "SwiftMessages", package: "SwiftMessages"), .product(name: "UITextView+Placeholder", package: "UITextView-Placeholder"), + .product(name: "FPSIndicator", package: "FPSIndicator"), + .product(name: "Floaty", package: "Floaty"), + .product(name: "Tabman", package: "Tabman"), + .product(name: "CoverFlowStackLayout", package: "CoverFlowStackLayout"), ] ), ] diff --git a/TwidereSDK/Sources/CoreDataStack/CoreDataStack.swift b/TwidereSDK/Sources/CoreDataStack/CoreDataStack.swift index 422a6317..0a61fa1f 100644 --- a/TwidereSDK/Sources/CoreDataStack/CoreDataStack.swift +++ b/TwidereSDK/Sources/CoreDataStack/CoreDataStack.swift @@ -24,6 +24,10 @@ public final class CoreDataStack { // output @Published public var didFinishLoad = false + + private lazy var persistHistoryManagedObjectContext: NSManagedObjectContext = { + return self.newTaskContext() + }() /// A persistent history token used for fetching transactions from the store. private var lastHistoryToken: NSPersistentHistoryToken? @@ -168,7 +172,7 @@ extension CoreDataStack { // seealso: `NSPersistentStoreRemoteChangeNotificationPostOptionKey` private func processRemoteStoreChange() async throws { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") - let context = self.newTaskContext() + let context = persistHistoryManagedObjectContext context.transactionAuthor = "PersistentHistoryContext" context.name = "PersistentHistoryContext" diff --git a/TwidereSDK/Sources/CoreDataStack/CoreDataStack.xcdatamodeld/.xccurrentversion b/TwidereSDK/Sources/CoreDataStack/CoreDataStack.xcdatamodeld/.xccurrentversion index 4998c1d6..c7412cd8 100644 --- a/TwidereSDK/Sources/CoreDataStack/CoreDataStack.xcdatamodeld/.xccurrentversion +++ b/TwidereSDK/Sources/CoreDataStack/CoreDataStack.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - CoreDataStack 6.xcdatamodel + CoreDataStack 8.xcdatamodel diff --git a/TwidereSDK/Sources/CoreDataStack/CoreDataStack.xcdatamodeld/CoreDataStack 7.xcdatamodel/contents b/TwidereSDK/Sources/CoreDataStack/CoreDataStack.xcdatamodeld/CoreDataStack 7.xcdatamodel/contents new file mode 100644 index 00000000..ff7b27b2 --- /dev/null +++ b/TwidereSDK/Sources/CoreDataStack/CoreDataStack.xcdatamodeld/CoreDataStack 7.xcdatamodel/contents @@ -0,0 +1,319 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/TwidereSDK/Sources/CoreDataStack/CoreDataStack.xcdatamodeld/CoreDataStack 8.xcdatamodel/contents b/TwidereSDK/Sources/CoreDataStack/CoreDataStack.xcdatamodeld/CoreDataStack 8.xcdatamodel/contents new file mode 100644 index 00000000..7e268763 --- /dev/null +++ b/TwidereSDK/Sources/CoreDataStack/CoreDataStack.xcdatamodeld/CoreDataStack 8.xcdatamodel/contents @@ -0,0 +1,330 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/TwidereSDK/Sources/CoreDataStack/Entity/App/History.swift b/TwidereSDK/Sources/CoreDataStack/Entity/App/History.swift new file mode 100644 index 00000000..5ffe0898 --- /dev/null +++ b/TwidereSDK/Sources/CoreDataStack/Entity/App/History.swift @@ -0,0 +1,253 @@ +// +// History.swift +// +// +// Created by MainasuK on 2022-7-29. +// + +import Foundation +import CoreData + +final public class History: NSManagedObject { + + public typealias Acct = Feed.Acct + + @NSManaged public private(set) var acctRaw: String + // sourcery: autoGenerateProperty + public var acct: Acct { + get { + Acct(rawValue: acctRaw) ?? .none + } + set { + acctRaw = newValue.rawValue + } + } + + // sourcery: autoGenerateProperty + @NSManaged public private(set) var timestamp: Date + + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var createdAt: Date + + // many-to-one relationship + // sourcery: autoUpdatableObject + @NSManaged public private(set) var twitterStatus: TwitterStatus? + // sourcery: autoUpdatableObject + @NSManaged public private(set) var twitterUser: TwitterUser? + // sourcery: autoUpdatableObject + @NSManaged public private(set) var mastodonStatus: MastodonStatus? + // sourcery: autoUpdatableObject + @NSManaged public private(set) var mastodonUser: MastodonUser? + +} + +extension History { + @objc public var sectionIdentifierByDay: String? { + get { + let keyPath = #keyPath(History.sectionIdentifierByDay) + willAccessValue(forKey: keyPath) + let _identifier = primitiveValue(forKey: keyPath) as? String + didAccessValue(forKey: keyPath) + + guard let identifier = _identifier else { + let timestamp = self.timestamp + let identifier = History.sectionIdentifier(from: timestamp) + + willChangeValue(forKey: keyPath) + setPrimitiveValue(identifier, forKey: keyPath) + didChangeValue(forKey: keyPath) + + return identifier + } + + return identifier + } + } +} + +extension History { + + @discardableResult + public static func insert( + into context: NSManagedObjectContext, + property: Property + ) -> History { + let object: History = context.insertObject() + object.configure(property: property) + return object + } + +} + +extension History: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + return [NSSortDescriptor(keyPath: \History.createdAt, ascending: false)] + } +} + +extension History { + + static func hasTwitterStatus() -> NSPredicate { + return NSPredicate(format: "%K != nil", #keyPath(History.twitterStatus)) + } + + static func hasTwitterUser() -> NSPredicate { + return NSPredicate(format: "%K != nil", #keyPath(History.twitterUser)) + } + + + static func hasMastodonStatus() -> NSPredicate { + return NSPredicate(format: "%K != nil", #keyPath(History.mastodonStatus)) + } + + static func hasMastodonUser() -> NSPredicate { + return NSPredicate(format: "%K != nil", #keyPath(History.mastodonUser)) + } + + public static func predicate(acct: Acct) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(History.acctRaw), acct.rawValue) + } + + public static func statusPredicate(acct: Acct) -> NSPredicate { + switch acct { + case .none: + return History.predicate(acct: acct) + case .twitter: + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + History.predicate(acct: acct), + History.hasTwitterStatus() + ]) + case .mastodon: + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + History.predicate(acct: acct), + History.hasMastodonStatus() + ]) + } + } + + public static func userPredicate(acct: Acct) -> NSPredicate { + switch acct { + case .none: + return History.predicate(acct: acct) + case .twitter: + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + History.predicate(acct: acct), + History.hasTwitterUser() + ]) + case .mastodon: + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + History.predicate(acct: acct), + History.hasMastodonUser() + ]) + } + } + +} + +// MARK: - AutoGenerateProperty +extension History: AutoGenerateProperty { + // sourcery:inline:History.AutoGenerateProperty + + // Generated using Sourcery + // DO NOT EDIT + public struct Property { + public let acct: Acct + public let timestamp: Date + public let createdAt: Date + + public init( + acct: Acct, + timestamp: Date, + createdAt: Date + ) { + self.acct = acct + self.timestamp = timestamp + self.createdAt = createdAt + } + } + + public func configure(property: Property) { + self.acct = property.acct + self.timestamp = property.timestamp + self.createdAt = property.createdAt + } + + public func update(property: Property) { + update(createdAt: property.createdAt) + } + + // sourcery:end +} + +// MARK: - AutoUpdatableObject +extension History: AutoUpdatableObject { + // sourcery:inline:History.AutoUpdatableObject + + // Generated using Sourcery + // DO NOT EDIT + public func update(createdAt: Date) { + if self.createdAt != createdAt { + self.createdAt = createdAt + } + } + public func update(twitterStatus: TwitterStatus?) { + if self.twitterStatus != twitterStatus { + self.twitterStatus = twitterStatus + } + } + public func update(twitterUser: TwitterUser?) { + if self.twitterUser != twitterUser { + self.twitterUser = twitterUser + } + } + public func update(mastodonStatus: MastodonStatus?) { + if self.mastodonStatus != mastodonStatus { + self.mastodonStatus = mastodonStatus + } + } + public func update(mastodonUser: MastodonUser?) { + if self.mastodonUser != mastodonUser { + self.mastodonUser = mastodonUser + } + } + // sourcery:end + + public func update(timestamp: Date) { + if self.timestamp != timestamp { + self.timestamp = timestamp + + setPrimitiveValue(nil, forKey: #keyPath(History.sectionIdentifierByDay)) + } + } +} + +extension History { + + public static func sectionIdentifier(from date: Date) -> String { + let calendar = Calendar.current + let year = calendar.component(.year, from: date) + let month = calendar.component(.month, from: date) + let day = calendar.component(.day, from: date) + + // yyyymmdd + let identifier = String(year * 10000 + month * 100 + day) + + return identifier + } + + public static func date(from sectionIdentifier: String) -> Date? { + guard let integer = Int(sectionIdentifier) else { return nil } + let year = integer / 10000 + let month = (integer - year * 10000) / 100 + let day = (integer - year * 10000 - month * 100) + + var dateComponents = DateComponents() + dateComponents.year = year + dateComponents.month = month + dateComponents.day = day + + guard let date = Calendar.current.date(from: dateComponents) else { return nil } + return date + } + +} diff --git a/TwidereSDK/Sources/CoreDataStack/Entity/Authentication/AuthenticationIndex.swift b/TwidereSDK/Sources/CoreDataStack/Entity/Authentication/AuthenticationIndex.swift index 45e4bf8f..a89420ca 100644 --- a/TwidereSDK/Sources/CoreDataStack/Entity/Authentication/AuthenticationIndex.swift +++ b/TwidereSDK/Sources/CoreDataStack/Entity/Authentication/AuthenticationIndex.swift @@ -26,6 +26,9 @@ final public class AuthenticationIndex: NSManagedObject { @NSManaged public private(set) var createdAt: Date @NSManaged public private(set) var activeAt: Date + // Record the home timeline latest active date + @NSManaged public private(set) var homeTimelineActiveAt: Date? + // one-to-one relationship @NSManaged public private(set) var twitterAuthentication: TwitterAuthentication? @NSManaged public private(set) var mastodonAuthentication: MastodonAuthentication? @@ -60,6 +63,12 @@ extension AuthenticationIndex { } } + public func update(homeTimelineActiveAt: Date) { + if self.homeTimelineActiveAt != homeTimelineActiveAt { + self.homeTimelineActiveAt = homeTimelineActiveAt + } + } + } extension AuthenticationIndex { diff --git a/TwidereSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonList.swift b/TwidereSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonList.swift index da871665..fb0d8e17 100644 --- a/TwidereSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonList.swift +++ b/TwidereSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonList.swift @@ -23,6 +23,9 @@ final public class MastodonList: NSManagedObject { // sourcery: autoUpdatableObject, autoGenerateProperty @NSManaged public private(set) var updatedAt: Date + // sourcery: autoUpdatableObject + @NSManaged public private(set) var activeAt: Date? + // many-to-one relationship // sourcery: autoGenerateRelationship @NSManaged public private(set) var owner: MastodonUser @@ -160,31 +163,10 @@ extension MastodonList: AutoUpdatableObject { self.updatedAt = updatedAt } } + public func update(activeAt: Date?) { + if self.activeAt != activeAt { + self.activeAt = activeAt + } + } // sourcery:end - -// public func update(`private`: Bool) { -// if self.`private` != `private` { -// self.`private` = `private` -// } -// } -// public func update(memberCount: Int64) { -// if self.memberCount != memberCount { -// self.memberCount = memberCount -// } -// } -// public func update(followerCount: Int64) { -// if self.followerCount != followerCount { -// self.followerCount = followerCount -// } -// } -// public func update(theDescription: String?) { -// if self.theDescription != theDescription { -// self.theDescription = theDescription -// } -// } -// public func update(createdAt: Date?) { -// if self.createdAt != createdAt { -// self.createdAt = createdAt -// } -// } } diff --git a/TwidereSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonNotificationSubscription.swift b/TwidereSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonNotificationSubscription.swift index 1f1c50a1..120ca42f 100644 --- a/TwidereSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonNotificationSubscription.swift +++ b/TwidereSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonNotificationSubscription.swift @@ -48,28 +48,44 @@ final public class MastodonNotificationSubscription: NSManagedObject { } extension MastodonNotificationSubscription { + @NSManaged private var mentionPreference: Data? + @NSManaged private var primitiveMentionPreferenceTransient: MentionPreference? // sourcery: autoUpdatableObject, autoGenerateProperty - @objc public var mentionPreference: MentionPreference { + @objc public private(set) var mentionPreferenceTransient: MentionPreference { get { - let keyPath = #keyPath(MastodonNotificationSubscription.mentionPreference) + let keyPath = #keyPath(mentionPreferenceTransient) willAccessValue(forKey: keyPath) - let _data = primitiveValue(forKey: keyPath) as? Data + let mentionPreference = primitiveMentionPreferenceTransient didAccessValue(forKey: keyPath) - do { - guard let data = _data, !data.isEmpty else { return MentionPreference() } - let mentionPreference = try JSONDecoder().decode(MentionPreference.self, from: data) + if let mentionPreference = mentionPreference { return mentionPreference - } catch { - assertionFailure(error.localizedDescription) - return MentionPreference() + } else { + do { + let _data = self.mentionPreference + guard let data = _data, !data.isEmpty else { + primitiveMentionPreferenceTransient = MentionPreference() + return MentionPreference() + } + let mentionPreference = try JSONDecoder().decode(MentionPreference.self, from: data) + primitiveMentionPreferenceTransient = mentionPreference + return mentionPreference + } catch { + assertionFailure(error.localizedDescription) + return MentionPreference() + } } } set { - let keyPath = #keyPath(MastodonNotificationSubscription.mentionPreference) - let data = try? JSONEncoder().encode(newValue) - willChangeValue(forKey: keyPath) - setPrimitiveValue(data, forKey: keyPath) - didChangeValue(forKey: keyPath) + let keyPath = #keyPath(mentionPreferenceTransient) + do { + let data = try JSONEncoder().encode(newValue) + mentionPreference = data + willChangeValue(forKey: keyPath) + primitiveMentionPreferenceTransient = newValue + didChangeValue(forKey: keyPath) + } catch { + assertionFailure() + } } } } @@ -124,7 +140,7 @@ extension MastodonNotificationSubscription: AutoGenerateProperty { public let poll: Bool public let createdAt: Date public let updatedAt: Date - public let mentionPreference: MentionPreference + public let mentionPreferenceTransient: MentionPreference public init( id: ID?, @@ -140,7 +156,7 @@ extension MastodonNotificationSubscription: AutoGenerateProperty { poll: Bool, createdAt: Date, updatedAt: Date, - mentionPreference: MentionPreference + mentionPreferenceTransient: MentionPreference ) { self.id = id self.domain = domain @@ -155,7 +171,7 @@ extension MastodonNotificationSubscription: AutoGenerateProperty { self.poll = poll self.createdAt = createdAt self.updatedAt = updatedAt - self.mentionPreference = mentionPreference + self.mentionPreferenceTransient = mentionPreferenceTransient } } @@ -173,7 +189,7 @@ extension MastodonNotificationSubscription: AutoGenerateProperty { self.poll = property.poll self.createdAt = property.createdAt self.updatedAt = property.updatedAt - self.mentionPreference = property.mentionPreference + self.mentionPreferenceTransient = property.mentionPreferenceTransient } public func update(property: Property) { @@ -190,7 +206,7 @@ extension MastodonNotificationSubscription: AutoGenerateProperty { update(poll: property.poll) update(createdAt: property.createdAt) update(updatedAt: property.updatedAt) - update(mentionPreference: property.mentionPreference) + update(mentionPreferenceTransient: property.mentionPreferenceTransient) } // sourcery:end @@ -290,9 +306,9 @@ extension MastodonNotificationSubscription: AutoUpdatableObject { self.updatedAt = updatedAt } } - public func update(mentionPreference: MentionPreference) { - if self.mentionPreference != mentionPreference { - self.mentionPreference = mentionPreference + public func update(mentionPreferenceTransient: MentionPreference) { + if self.mentionPreferenceTransient != mentionPreferenceTransient { + self.mentionPreferenceTransient = mentionPreferenceTransient } } diff --git a/TwidereSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonStatus.swift b/TwidereSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonStatus.swift index d4161332..953d87e9 100644 --- a/TwidereSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonStatus.swift +++ b/TwidereSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonStatus.swift @@ -79,6 +79,7 @@ final public class MastodonStatus: NSManagedObject { @NSManaged public private(set) var feeds: Set @NSManaged public private(set) var repostFrom: Set @NSManaged public private(set) var notifications: Set + @NSManaged public private(set) var histories: Set // many-to-one relationship // sourcery: autoGenerateRelationship @@ -93,78 +94,138 @@ final public class MastodonStatus: NSManagedObject { } extension MastodonStatus { + @NSManaged private var attachments: Data? + @NSManaged private var primitiveAttachmentsTransient: [MastodonAttachment]? // sourcery: autoUpdatableObject, autoGenerateProperty - @objc public var attachments: [MastodonAttachment] { + @objc public private(set) var attachmentsTransient: [MastodonAttachment] { get { - let keyPath = #keyPath(MastodonStatus.attachments) + let keyPath = #keyPath(attachmentsTransient) willAccessValue(forKey: keyPath) - let _data = primitiveValue(forKey: keyPath) as? Data + let attachments = primitiveAttachmentsTransient didAccessValue(forKey: keyPath) - do { - guard let data = _data, !data.isEmpty else { return [] } - let attachments = try JSONDecoder().decode([MastodonAttachment].self, from: data) + if let attachments = attachments { return attachments - } catch { - assertionFailure(error.localizedDescription) - return [] + } else { + do { + let _data = self.attachments + guard let data = _data, !data.isEmpty else { + primitiveAttachmentsTransient = [] + return [] + } + let attachments = try JSONDecoder().decode([MastodonAttachment].self, from: data) + primitiveAttachmentsTransient = attachments + return attachments + } catch { + assertionFailure(error.localizedDescription) + return [] + } } } set { - let keyPath = #keyPath(MastodonStatus.attachments) - let data = try? JSONEncoder().encode(newValue) - willChangeValue(forKey: keyPath) - setPrimitiveValue(data, forKey: keyPath) - didChangeValue(forKey: keyPath) + let keyPath = #keyPath(attachmentsTransient) + do { + if newValue.isEmpty { + attachments = nil + } else { + let data = try JSONEncoder().encode(newValue) + attachments = data + } + willChangeValue(forKey: keyPath) + primitiveAttachmentsTransient = newValue + didChangeValue(forKey: keyPath) + } catch { + assertionFailure() + } } } + @NSManaged private var emojis: Data? + @NSManaged private var primitiveEmojisTransient: [MastodonEmoji]? // sourcery: autoUpdatableObject, autoGenerateProperty - @objc public var emojis: [MastodonEmoji] { + @objc public private(set) var emojisTransient: [MastodonEmoji] { get { - let keyPath = #keyPath(MastodonStatus.emojis) + let keyPath = #keyPath(emojisTransient) willAccessValue(forKey: keyPath) - let _data = primitiveValue(forKey: keyPath) as? Data + let emojis = primitiveEmojisTransient didAccessValue(forKey: keyPath) - do { - guard let data = _data else { return [] } - let emojis = try JSONDecoder().decode([MastodonEmoji].self, from: data) + if let emojis = emojis { return emojis - } catch { - assertionFailure(error.localizedDescription) - return [] + } else { + do { + let _data = self.emojis + guard let data = _data, !data.isEmpty else { + primitiveEmojisTransient = [] + return [] + } + let emojis = try JSONDecoder().decode([MastodonEmoji].self, from: data) + primitiveEmojisTransient = emojis + return emojis + } catch { + assertionFailure(error.localizedDescription) + return [] + } } } set { - let keyPath = #keyPath(MastodonStatus.emojis) - let data = try? JSONEncoder().encode(newValue) - willChangeValue(forKey: keyPath) - setPrimitiveValue(data, forKey: keyPath) - didChangeValue(forKey: keyPath) + let keyPath = #keyPath(emojisTransient) + do { + if newValue.isEmpty { + emojis = nil + } else { + let data = try JSONEncoder().encode(newValue) + emojis = data + } + willChangeValue(forKey: keyPath) + primitiveEmojisTransient = newValue + didChangeValue(forKey: keyPath) + } catch { + assertionFailure() + } } } + @NSManaged private var mentions: Data? + @NSManaged private var primitiveMentionsTransient: [MastodonMention]? // sourcery: autoUpdatableObject, autoGenerateProperty - @objc public var mentions: [MastodonMention] { + @objc public private(set) var mentionsTransient: [MastodonMention] { get { - let keyPath = #keyPath(MastodonStatus.mentions) + let keyPath = #keyPath(mentionsTransient) willAccessValue(forKey: keyPath) - let _data = primitiveValue(forKey: keyPath) as? Data + let mentions = primitiveMentionsTransient didAccessValue(forKey: keyPath) - do { - guard let data = _data else { return [] } - let emojis = try JSONDecoder().decode([MastodonMention].self, from: data) - return emojis - } catch { - assertionFailure(error.localizedDescription) - return [] + if let mentions = mentions { + return mentions + } else { + do { + let _data = self.mentions + guard let data = _data, !data.isEmpty else { + primitiveMentionsTransient = [] + return [] + } + let mentions = try JSONDecoder().decode([MastodonMention].self, from: data) + primitiveMentionsTransient = mentions + return mentions + } catch { + assertionFailure(error.localizedDescription) + return [] + } } } set { - let keyPath = #keyPath(MastodonStatus.mentions) - let data = try? JSONEncoder().encode(newValue) - willChangeValue(forKey: keyPath) - setPrimitiveValue(data, forKey: keyPath) - didChangeValue(forKey: keyPath) + let keyPath = #keyPath(mentionsTransient) + do { + if newValue.isEmpty { + mentions = nil + } else { + let data = try JSONEncoder().encode(newValue) + mentions = data + } + willChangeValue(forKey: keyPath) + primitiveMentionsTransient = newValue + didChangeValue(forKey: keyPath) + } catch { + assertionFailure() + } } } } @@ -248,9 +309,9 @@ extension MastodonStatus: AutoGenerateProperty { public let replyToUserID: MastodonStatus.ID? public let createdAt: Date public let updatedAt: Date - public let attachments: [MastodonAttachment] - public let emojis: [MastodonEmoji] - public let mentions: [MastodonMention] + public let attachmentsTransient: [MastodonAttachment] + public let emojisTransient: [MastodonEmoji] + public let mentionsTransient: [MastodonMention] public init( id: ID, @@ -271,9 +332,9 @@ extension MastodonStatus: AutoGenerateProperty { replyToUserID: MastodonStatus.ID?, createdAt: Date, updatedAt: Date, - attachments: [MastodonAttachment], - emojis: [MastodonEmoji], - mentions: [MastodonMention] + attachmentsTransient: [MastodonAttachment], + emojisTransient: [MastodonEmoji], + mentionsTransient: [MastodonMention] ) { self.id = id self.domain = domain @@ -293,9 +354,9 @@ extension MastodonStatus: AutoGenerateProperty { self.replyToUserID = replyToUserID self.createdAt = createdAt self.updatedAt = updatedAt - self.attachments = attachments - self.emojis = emojis - self.mentions = mentions + self.attachmentsTransient = attachmentsTransient + self.emojisTransient = emojisTransient + self.mentionsTransient = mentionsTransient } } @@ -318,9 +379,9 @@ extension MastodonStatus: AutoGenerateProperty { self.replyToUserID = property.replyToUserID self.createdAt = property.createdAt self.updatedAt = property.updatedAt - self.attachments = property.attachments - self.emojis = property.emojis - self.mentions = property.mentions + self.attachmentsTransient = property.attachmentsTransient + self.emojisTransient = property.emojisTransient + self.mentionsTransient = property.mentionsTransient } public func update(property: Property) { @@ -339,9 +400,9 @@ extension MastodonStatus: AutoGenerateProperty { update(replyToUserID: property.replyToUserID) update(createdAt: property.createdAt) update(updatedAt: property.updatedAt) - update(attachments: property.attachments) - update(emojis: property.emojis) - update(mentions: property.mentions) + update(attachmentsTransient: property.attachmentsTransient) + update(emojisTransient: property.emojisTransient) + update(mentionsTransient: property.mentionsTransient) } // sourcery:end } @@ -467,19 +528,19 @@ extension MastodonStatus: AutoUpdatableObject { self.updatedAt = updatedAt } } - public func update(attachments: [MastodonAttachment]) { - if self.attachments != attachments { - self.attachments = attachments + public func update(attachmentsTransient: [MastodonAttachment]) { + if self.attachmentsTransient != attachmentsTransient { + self.attachmentsTransient = attachmentsTransient } } - public func update(emojis: [MastodonEmoji]) { - if self.emojis != emojis { - self.emojis = emojis + public func update(emojisTransient: [MastodonEmoji]) { + if self.emojisTransient != emojisTransient { + self.emojisTransient = emojisTransient } } - public func update(mentions: [MastodonMention]) { - if self.mentions != mentions { - self.mentions = mentions + public func update(mentionsTransient: [MastodonMention]) { + if self.mentionsTransient != mentionsTransient { + self.mentionsTransient = mentionsTransient } } // sourcery:end diff --git a/TwidereSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonUser.swift b/TwidereSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonUser.swift index eeba4028..1bcc78fc 100644 --- a/TwidereSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonUser.swift +++ b/TwidereSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonUser.swift @@ -66,6 +66,7 @@ final public class MastodonUser: NSManagedObject { // one-to-many relationship @NSManaged public private(set) var statuses: Set @NSManaged public private(set) var notifications: Set + @NSManaged public private(set) var histories: Set // many-to-many relationship @NSManaged public private(set) var like: Set @@ -96,53 +97,93 @@ final public class MastodonUser: NSManagedObject { } extension MastodonUser { + @NSManaged private var emojis: Data? + @NSManaged private var primitiveEmojisTransient: [MastodonEmoji]? // sourcery: autoUpdatableObject, autoGenerateProperty - @objc public var emojis: [MastodonEmoji] { + @objc public private(set) var emojisTransient: [MastodonEmoji] { get { - let keyPath = #keyPath(MastodonUser.emojis) + let keyPath = #keyPath(emojisTransient) willAccessValue(forKey: keyPath) - let _data = primitiveValue(forKey: keyPath) as? Data + let emojis = primitiveEmojisTransient didAccessValue(forKey: keyPath) - do { - guard let data = _data else { return [] } - let emojis = try JSONDecoder().decode([MastodonEmoji].self, from: data) + if let emojis = emojis { return emojis - } catch { - assertionFailure(error.localizedDescription) - return [] + } else { + do { + let _data = self.emojis + guard let data = _data, !data.isEmpty else { + primitiveEmojisTransient = [] + return [] + } + let emojis = try JSONDecoder().decode([MastodonEmoji].self, from: data) + primitiveEmojisTransient = emojis + return emojis + } catch { + assertionFailure(error.localizedDescription) + return [] + } } } set { - let keyPath = #keyPath(MastodonUser.emojis) - let data = try? JSONEncoder().encode(newValue) - willChangeValue(forKey: keyPath) - setPrimitiveValue(data, forKey: keyPath) - didChangeValue(forKey: keyPath) + let keyPath = #keyPath(emojisTransient) + do { + if newValue.isEmpty { + emojis = nil + } else { + let data = try JSONEncoder().encode(newValue) + emojis = data + } + willChangeValue(forKey: keyPath) + primitiveEmojisTransient = newValue + didChangeValue(forKey: keyPath) + } catch { + assertionFailure() + } } } + @NSManaged private var fields: Data? + @NSManaged private var primitiveFieldsTransient: [MastodonField]? // sourcery: autoUpdatableObject, autoGenerateProperty - @objc public var fields: [MastodonField] { + @objc public private(set) var fieldsTransient: [MastodonField] { get { - let keyPath = #keyPath(MastodonUser.fields) + let keyPath = #keyPath(fieldsTransient) willAccessValue(forKey: keyPath) - let _data = primitiveValue(forKey: keyPath) as? Data + let fields = primitiveFieldsTransient didAccessValue(forKey: keyPath) - do { - guard let data = _data else { return [] } - let fields = try JSONDecoder().decode([MastodonField].self, from: data) + if let fields = fields { return fields - } catch { - assertionFailure(error.localizedDescription) - return [] + } else { + do { + let _data = self.fields + guard let data = _data, !data.isEmpty else { + primitiveFieldsTransient = [] + return [] + } + let fields = try JSONDecoder().decode([MastodonField].self, from: data) + primitiveFieldsTransient = fields + return fields + } catch { + assertionFailure(error.localizedDescription) + return [] + } } } set { - let keyPath = #keyPath(MastodonUser.fields) - let data = try? JSONEncoder().encode(newValue) - willChangeValue(forKey: keyPath) - setPrimitiveValue(data, forKey: keyPath) - didChangeValue(forKey: keyPath) + let keyPath = #keyPath(fieldsTransient) + do { + if newValue.isEmpty { + fields = nil + } else { + let data = try JSONEncoder().encode(newValue) + fields = data + } + willChangeValue(forKey: keyPath) + primitiveFieldsTransient = newValue + didChangeValue(forKey: keyPath) + } catch { + assertionFailure() + } } } } @@ -234,8 +275,8 @@ extension MastodonUser: AutoGenerateProperty { public let suspended: Bool public let createdAt: Date public let updatedAt: Date - public let emojis: [MastodonEmoji] - public let fields: [MastodonField] + public let emojisTransient: [MastodonEmoji] + public let fieldsTransient: [MastodonField] public init( domain: String, @@ -257,8 +298,8 @@ extension MastodonUser: AutoGenerateProperty { suspended: Bool, createdAt: Date, updatedAt: Date, - emojis: [MastodonEmoji], - fields: [MastodonField] + emojisTransient: [MastodonEmoji], + fieldsTransient: [MastodonField] ) { self.domain = domain self.id = id @@ -279,8 +320,8 @@ extension MastodonUser: AutoGenerateProperty { self.suspended = suspended self.createdAt = createdAt self.updatedAt = updatedAt - self.emojis = emojis - self.fields = fields + self.emojisTransient = emojisTransient + self.fieldsTransient = fieldsTransient } } @@ -304,8 +345,8 @@ extension MastodonUser: AutoGenerateProperty { self.suspended = property.suspended self.createdAt = property.createdAt self.updatedAt = property.updatedAt - self.emojis = property.emojis - self.fields = property.fields + self.emojisTransient = property.emojisTransient + self.fieldsTransient = property.fieldsTransient } public func update(property: Property) { @@ -326,8 +367,8 @@ extension MastodonUser: AutoGenerateProperty { update(suspended: property.suspended) update(createdAt: property.createdAt) update(updatedAt: property.updatedAt) - update(emojis: property.emojis) - update(fields: property.fields) + update(emojisTransient: property.emojisTransient) + update(fieldsTransient: property.fieldsTransient) } // sourcery:end } @@ -423,14 +464,14 @@ extension MastodonUser: AutoUpdatableObject { self.updatedAt = updatedAt } } - public func update(emojis: [MastodonEmoji]) { - if self.emojis != emojis { - self.emojis = emojis + public func update(emojisTransient: [MastodonEmoji]) { + if self.emojisTransient != emojisTransient { + self.emojisTransient = emojisTransient } } - public func update(fields: [MastodonField]) { - if self.fields != fields { - self.fields = fields + public func update(fieldsTransient: [MastodonField]) { + if self.fieldsTransient != fieldsTransient { + self.fieldsTransient = fieldsTransient } } // sourcery:end diff --git a/TwidereSDK/Sources/CoreDataStack/Entity/Twitter/TwitterList.swift b/TwidereSDK/Sources/CoreDataStack/Entity/Twitter/TwitterList.swift index 733e895e..601ec544 100644 --- a/TwidereSDK/Sources/CoreDataStack/Entity/Twitter/TwitterList.swift +++ b/TwidereSDK/Sources/CoreDataStack/Entity/Twitter/TwitterList.swift @@ -32,6 +32,9 @@ final public class TwitterList: NSManagedObject { // sourcery: autoUpdatableObject, autoGenerateProperty @NSManaged public private(set) var updatedAt: Date + // sourcery: autoUpdatableObject + @NSManaged public private(set) var activeAt: Date? + // many-to-one relationship // sourcery: autoGenerateRelationship @NSManaged public private(set) var owner: TwitterUser @@ -167,6 +170,11 @@ extension TwitterList: AutoUpdatableObject { self.updatedAt = updatedAt } } + public func update(activeAt: Date?) { + if self.activeAt != activeAt { + self.activeAt = activeAt + } + } // sourcery:end public func update(`private`: Bool) { diff --git a/TwidereSDK/Sources/CoreDataStack/Entity/Twitter/TwitterStatus.swift b/TwidereSDK/Sources/CoreDataStack/Entity/Twitter/TwitterStatus.swift index f4f1e660..938c8a56 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 @@ -54,15 +59,16 @@ final public class TwitterStatus: NSManagedObject { // one-to-many relationship @NSManaged public private(set) var feeds: Set - + @NSManaged public private(set) var histories: Set + // many-to-one relationship // sourcery: autoGenerateRelationship @NSManaged public private(set) var author: TwitterUser - // sourcery: autoGenerateRelationship + // sourcery: autoUpdatableObject, autoGenerateRelationship @NSManaged public private(set) var repost: TwitterStatus? - // sourcery: autoGenerateRelationship + // sourcery: autoUpdatableObject, autoGenerateRelationship @NSManaged public private(set) var quote: TwitterStatus? - // sourcery: autoGenerateRelationship, autoUpdatableObject + // sourcery: autoUpdatableObject, autoGenerateRelationship @NSManaged public private(set) var replyTo: TwitterStatus? // one-to-many relationship @@ -77,103 +83,171 @@ final public class TwitterStatus: NSManagedObject { } extension TwitterStatus { + @NSManaged private var attachments: Data? + @NSManaged private var primitiveAttachmentsTransient: [TwitterAttachment]? // sourcery: autoUpdatableObject - @objc public var attachments: [TwitterAttachment] { + @objc public private(set) var attachmentsTransient: [TwitterAttachment] { get { - let keyPath = #keyPath(TwitterStatus.attachments) + let keyPath = #keyPath(attachmentsTransient) willAccessValue(forKey: keyPath) - let _data = primitiveValue(forKey: keyPath) as? Data + let attachments = primitiveAttachmentsTransient didAccessValue(forKey: keyPath) - do { - guard let data = _data else { return [] } - let attachments = try JSONDecoder().decode([TwitterAttachment].self, from: data) + if let attachments = attachments { return attachments - } catch { - assertionFailure(error.localizedDescription) - return [] + } else { + do { + let _data = self.attachments + guard let data = _data, !data.isEmpty else { + primitiveAttachmentsTransient = [] + return [] + } + let attachments = try JSONDecoder().decode([TwitterAttachment].self, from: data) + primitiveAttachmentsTransient = attachments + return attachments + } catch { + assertionFailure(error.localizedDescription) + return [] + } } } set { - let keyPath = #keyPath(TwitterStatus.attachments) - let data = try? JSONEncoder().encode(newValue) - willChangeValue(forKey: keyPath) - setPrimitiveValue(data, forKey: keyPath) - didChangeValue(forKey: keyPath) + let keyPath = #keyPath(attachmentsTransient) + do { + if newValue.isEmpty { + attachments = nil + } else { + let data = try JSONEncoder().encode(newValue) + attachments = data + } + willChangeValue(forKey: keyPath) + primitiveAttachmentsTransient = newValue + didChangeValue(forKey: keyPath) + } catch { + assertionFailure() + } } } + @NSManaged private var location: Data? + @NSManaged private var primitiveLocationTransient: TwitterLocation? // sourcery: autoUpdatableObject - @objc public var location: TwitterLocation? { + @objc public private(set) var locationTransient: TwitterLocation? { get { - let keyPath = #keyPath(TwitterStatus.location) + let keyPath = #keyPath(locationTransient) willAccessValue(forKey: keyPath) - let _data = primitiveValue(forKey: keyPath) as? Data + let location = primitiveLocationTransient didAccessValue(forKey: keyPath) - do { - guard let data = _data else { return nil } - let location = try JSONDecoder().decode(TwitterLocation.self, from: data) + if let location = location { return location - } catch { - assertionFailure(error.localizedDescription) - return nil + } else { + do { + let _data = self.location + guard let data = _data, !data.isEmpty else { + primitiveLocationTransient = nil + return nil + } + let location = try JSONDecoder().decode(TwitterLocation.self, from: data) + primitiveLocationTransient = location + return location + } catch { + assertionFailure(error.localizedDescription) + return nil + } } } set { - let keyPath = #keyPath(TwitterStatus.location) - let data = try? JSONEncoder().encode(newValue) - willChangeValue(forKey: keyPath) - setPrimitiveValue(data, forKey: keyPath) - didChangeValue(forKey: keyPath) + let keyPath = #keyPath(locationTransient) + do { + let data = try newValue.flatMap { try JSONEncoder().encode($0) } + location = data + willChangeValue(forKey: keyPath) + primitiveLocationTransient = newValue + didChangeValue(forKey: keyPath) + } catch { + assertionFailure() + } } } + @NSManaged private var entities: Data? + @NSManaged private var primitiveEntitiesTransient: TwitterEntity? // sourcery: autoUpdatableObject - @objc public var entities: TwitterEntity? { + @objc public private(set) var entitiesTransient: TwitterEntity? { get { - let keyPath = #keyPath(TwitterStatus.entities) + let keyPath = #keyPath(entitiesTransient) willAccessValue(forKey: keyPath) - let _data = primitiveValue(forKey: keyPath) as? Data + let entities = primitiveEntitiesTransient didAccessValue(forKey: keyPath) - do { - guard let data = _data else { return nil } - let entities = try JSONDecoder().decode(TwitterEntity.self, from: data) + if let entities = entities { return entities - } catch { - assertionFailure(error.localizedDescription) - return nil + } else { + do { + let _data = self.entities + guard let data = _data, !data.isEmpty else { + primitiveEntitiesTransient = nil + return nil + } + let entities = try JSONDecoder().decode(TwitterEntity.self, from: data) + primitiveEntitiesTransient = entities + return entities + } catch { + assertionFailure(error.localizedDescription) + return nil + } } } set { - let keyPath = #keyPath(TwitterStatus.entities) - let data = try? JSONEncoder().encode(newValue) - willChangeValue(forKey: keyPath) - setPrimitiveValue(data, forKey: keyPath) - didChangeValue(forKey: keyPath) + let keyPath = #keyPath(entitiesTransient) + do { + let data = try newValue.flatMap { try JSONEncoder().encode($0) } + entities = data + willChangeValue(forKey: keyPath) + primitiveEntitiesTransient = newValue + didChangeValue(forKey: keyPath) + } catch { + assertionFailure() + } } } + @NSManaged private var replySettings: Data? + @NSManaged private var primitiveReplySettingsTransient: TwitterReplySettings? // sourcery: autoUpdatableObject - @objc public var replySettings: TwitterReplySettings? { + @objc public private(set) var replySettingsTransient: TwitterReplySettings? { get { - let keyPath = #keyPath(TwitterStatus.replySettings) + let keyPath = #keyPath(replySettingsTransient) willAccessValue(forKey: keyPath) - let _data = primitiveValue(forKey: keyPath) as? Data + let replySettings = primitiveReplySettingsTransient didAccessValue(forKey: keyPath) - do { - guard let data = _data else { return nil } - let replySettings = try JSONDecoder().decode(TwitterReplySettings.self, from: data) + if let replySettings = replySettings { return replySettings - } catch { - assertionFailure(error.localizedDescription) - return nil + } else { + do { + let _data = self.replySettings + guard let data = _data, !data.isEmpty else { + primitiveReplySettingsTransient = nil + return nil + } + let replySettings = try JSONDecoder().decode(TwitterReplySettings.self, from: data) + primitiveReplySettingsTransient = replySettings + return replySettings + } catch { + assertionFailure(error.localizedDescription) + return nil + } } } set { - let keyPath = #keyPath(TwitterStatus.replySettings) - let data = try? JSONEncoder().encode(newValue) - willChangeValue(forKey: keyPath) - setPrimitiveValue(data, forKey: keyPath) - didChangeValue(forKey: keyPath) + let keyPath = #keyPath(replySettingsTransient) + do { + let data = try newValue.flatMap { try JSONEncoder().encode($0) } + replySettings = data + willChangeValue(forKey: keyPath) + primitiveReplySettingsTransient = newValue + didChangeValue(forKey: keyPath) + } catch { + assertionFailure() + } } } } @@ -233,6 +307,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 @@ -247,6 +322,7 @@ extension TwitterStatus: AutoGenerateProperty { source: String?, replyToStatusID: TwitterStatus.ID?, replyToUserID: TwitterUser.ID?, + isMediaSensitive: Bool, createdAt: Date, updatedAt: Date ) { @@ -260,6 +336,7 @@ extension TwitterStatus: AutoGenerateProperty { self.source = source self.replyToStatusID = replyToStatusID self.replyToUserID = replyToUserID + self.isMediaSensitive = isMediaSensitive self.createdAt = createdAt self.updatedAt = updatedAt } @@ -276,6 +353,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 } @@ -287,6 +365,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) } @@ -372,6 +451,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 @@ -382,29 +471,39 @@ extension TwitterStatus: AutoUpdatableObject { self.updatedAt = updatedAt } } + public func update(repost: TwitterStatus?) { + if self.repost != repost { + self.repost = repost + } + } + public func update(quote: TwitterStatus?) { + if self.quote != quote { + self.quote = quote + } + } public func update(replyTo: TwitterStatus?) { if self.replyTo != replyTo { self.replyTo = replyTo } } - public func update(attachments: [TwitterAttachment]) { - if self.attachments != attachments { - self.attachments = attachments + public func update(attachmentsTransient: [TwitterAttachment]) { + if self.attachmentsTransient != attachmentsTransient { + self.attachmentsTransient = attachmentsTransient } } - public func update(location: TwitterLocation?) { - if self.location != location { - self.location = location + public func update(locationTransient: TwitterLocation?) { + if self.locationTransient != locationTransient { + self.locationTransient = locationTransient } } - public func update(entities: TwitterEntity?) { - if self.entities != entities { - self.entities = entities + public func update(entitiesTransient: TwitterEntity?) { + if self.entitiesTransient != entitiesTransient { + self.entitiesTransient = entitiesTransient } } - public func update(replySettings: TwitterReplySettings?) { - if self.replySettings != replySettings { - self.replySettings = replySettings + public func update(replySettingsTransient: TwitterReplySettings?) { + if self.replySettingsTransient != replySettingsTransient { + self.replySettingsTransient = replySettingsTransient } } // sourcery:end diff --git a/TwidereSDK/Sources/CoreDataStack/Entity/Twitter/TwitterUser.swift b/TwidereSDK/Sources/CoreDataStack/Entity/Twitter/TwitterUser.swift index e1cc1204..fae31bef 100644 --- a/TwidereSDK/Sources/CoreDataStack/Entity/Twitter/TwitterUser.swift +++ b/TwidereSDK/Sources/CoreDataStack/Entity/Twitter/TwitterUser.swift @@ -36,13 +36,13 @@ public final class TwitterUser: NSManagedObject { // sourcery: autoUpdatableObject, autoGenerateProperty @NSManaged public private(set) var verified: Bool - // sourcery: autoUpdatableObject, autoGenerateProperty + // sourcery: autoGenerateProperty @NSManaged public private(set) var statusesCount: Int64 - // sourcery: autoUpdatableObject, autoGenerateProperty + // sourcery: autoGenerateProperty @NSManaged public private(set) var followingCount: Int64 - // sourcery: autoUpdatableObject, autoGenerateProperty + // sourcery: autoGenerateProperty @NSManaged public private(set) var followersCount: Int64 - // sourcery: autoUpdatableObject, autoGenerateProperty + // sourcery: autoGenerateProperty @NSManaged public private(set) var listedCount: Int64 // sourcery: autoUpdatableObject, autoGenerateProperty @@ -57,7 +57,8 @@ public final class TwitterUser: NSManagedObject { @NSManaged public private(set) var statuses: Set @NSManaged public private(set) var savedSearches: Set @NSManaged public private(set) var ownedLists: Set - + @NSManaged public private(set) var histories: Set + // many-to-many relationship @NSManaged public private(set) var like: Set @NSManaged public private(set) var reposts: Set @@ -78,53 +79,93 @@ public final class TwitterUser: NSManagedObject { } extension TwitterUser { + @NSManaged private var bioEntities: Data? + @NSManaged private var primitiveBioEntitiesTransient: TwitterEntity? // sourcery: autoUpdatableObject - @objc public var bioEntities: TwitterEntity? { + @objc public private(set) var bioEntitiesTransient: TwitterEntity? { get { - let keyPath = #keyPath(TwitterUser.bioEntities) + let keyPath = #keyPath(bioEntitiesTransient) willAccessValue(forKey: keyPath) - let _data = primitiveValue(forKey: keyPath) as? Data + let bioEntities = primitiveBioEntitiesTransient didAccessValue(forKey: keyPath) - do { - guard let data = _data else { return nil } - let entities = try JSONDecoder().decode(TwitterEntity.self, from: data) - return entities - } catch { - assertionFailure(error.localizedDescription) - return nil + if let bioEntities = bioEntities { + return bioEntities + } else { + do { + let _data = self.bioEntities + guard let data = _data, !data.isEmpty else { + primitiveBioEntitiesTransient = nil + return nil + } + let entities = try JSONDecoder().decode(TwitterEntity.self, from: data) + primitiveBioEntitiesTransient = entities + return entities + } catch { + assertionFailure(error.localizedDescription) + return nil + } } } set { - let keyPath = #keyPath(TwitterUser.bioEntities) - let data = try? JSONEncoder().encode(newValue) - willChangeValue(forKey: keyPath) - setPrimitiveValue(data, forKey: keyPath) - didChangeValue(forKey: keyPath) + let keyPath = #keyPath(bioEntitiesTransient) + do { + if let newValue = newValue { + let data = try JSONEncoder().encode(newValue) + bioEntities = data + } else { + bioEntities = nil + } + willChangeValue(forKey: keyPath) + primitiveBioEntitiesTransient = newValue + didChangeValue(forKey: keyPath) + } catch { + assertionFailure() + } } } + @NSManaged private var urlEntities: Data? + @NSManaged private var primitiveUrlEntitiesTransient: TwitterEntity? // sourcery: autoUpdatableObject - @objc public var urlEntities: TwitterEntity? { + @objc public private(set) var urlEntitiesTransient: TwitterEntity? { get { - let keyPath = #keyPath(TwitterUser.urlEntities) + let keyPath = #keyPath(urlEntitiesTransient) willAccessValue(forKey: keyPath) - let _data = primitiveValue(forKey: keyPath) as? Data + let urlEntities = primitiveUrlEntitiesTransient didAccessValue(forKey: keyPath) - do { - guard let data = _data else { return nil } - let entities = try JSONDecoder().decode(TwitterEntity.self, from: data) - return entities - } catch { - assertionFailure(error.localizedDescription) - return nil + if let urlEntities = urlEntities { + return urlEntities + } else { + do { + let _data = self.urlEntities + guard let data = _data, !data.isEmpty else { + primitiveUrlEntitiesTransient = nil + return nil + } + let entities = try JSONDecoder().decode(TwitterEntity.self, from: data) + primitiveUrlEntitiesTransient = entities + return entities + } catch { + assertionFailure(error.localizedDescription) + return nil + } } } set { - let keyPath = #keyPath(TwitterUser.urlEntities) - let data = try? JSONEncoder().encode(newValue) - willChangeValue(forKey: keyPath) - setPrimitiveValue(data, forKey: keyPath) - didChangeValue(forKey: keyPath) + let keyPath = #keyPath(urlEntitiesTransient) + do { + if let newValue = newValue { + let data = try JSONEncoder().encode(newValue) + urlEntities = data + } else { + urlEntities = nil + } + willChangeValue(forKey: keyPath) + primitiveUrlEntitiesTransient = newValue + didChangeValue(forKey: keyPath) + } catch { + assertionFailure() + } } } } @@ -161,6 +202,7 @@ extension TwitterUser { public static func predicate(username: String) -> NSPredicate { return NSPredicate(format: "%K == %@", #keyPath(TwitterUser.username), username) + } } @@ -251,10 +293,6 @@ extension TwitterUser: AutoGenerateProperty { update(protected: property.protected) update(url: property.url) update(verified: property.verified) - update(statusesCount: property.statusesCount) - update(followingCount: property.followingCount) - update(followersCount: property.followersCount) - update(listedCount: property.listedCount) update(updatedAt: property.updatedAt) } // sourcery:end @@ -322,43 +360,44 @@ extension TwitterUser: AutoUpdatableObject { self.verified = verified } } - public func update(statusesCount: Int64) { - if self.statusesCount != statusesCount { - self.statusesCount = statusesCount - } - } - public func update(followingCount: Int64) { - if self.followingCount != followingCount { - self.followingCount = followingCount - } - } - public func update(followersCount: Int64) { - if self.followersCount != followersCount { - self.followersCount = followersCount - } - } - public func update(listedCount: Int64) { - if self.listedCount != listedCount { - self.listedCount = listedCount - } - } public func update(updatedAt: Date) { if self.updatedAt != updatedAt { self.updatedAt = updatedAt } } - public func update(bioEntities: TwitterEntity?) { - if self.bioEntities != bioEntities { - self.bioEntities = bioEntities + public func update(bioEntitiesTransient: TwitterEntity?) { + if self.bioEntitiesTransient != bioEntitiesTransient { + self.bioEntitiesTransient = bioEntitiesTransient } } - public func update(urlEntities: TwitterEntity?) { - if self.urlEntities != urlEntities { - self.urlEntities = urlEntities + public func update(urlEntitiesTransient: TwitterEntity?) { + if self.urlEntitiesTransient != urlEntitiesTransient { + self.urlEntitiesTransient = urlEntitiesTransient } } // sourcery:end + public func update(statusesCount: Int64) { + if self.statusesCount != statusesCount, statusesCount >= 0 { + self.statusesCount = statusesCount + } + } + public func update(followingCount: Int64) { + if self.followingCount != followingCount, followingCount >= 0 { + self.followingCount = followingCount + } + } + public func update(followersCount: Int64) { + if self.followersCount != followersCount, followersCount >= 0 { + self.followersCount = followersCount + } + } + public func update(listedCount: Int64) { + if self.listedCount != listedCount, listedCount >= 0 { + self.listedCount = listedCount + } + } + public func update(isFollow: Bool, by user: TwitterUser) { if isFollow { if !followingBy.contains(user) { diff --git a/TwidereSDK/Sources/CoreDataStack/Generated/AutoGenerateProperty.generated.swift b/TwidereSDK/Sources/CoreDataStack/Generated/AutoGenerateProperty.generated.swift new file mode 100644 index 00000000..eed4b7e0 --- /dev/null +++ b/TwidereSDK/Sources/CoreDataStack/Generated/AutoGenerateProperty.generated.swift @@ -0,0 +1,36 @@ +// Generated using Sourcery 1.8.1 — https://github.com/krzysztofzablocki/Sourcery +// DO NOT EDIT + + + + + + + + + +// sourcery:inline:MastodonStatus.MastodonStatus.AutoGenerateProperty + +// Generated using Sourcery +// DO NOT EDIT +public struct Property { + + public init( + ) { + } +} + +public func configure(property: Property) { +} + +public func update(property: Property) { +} +// sourcery:end + + + + + + + + diff --git a/TwidereSDK/Sources/CoreDataStack/Generated/AutoGenerateRelationship.generated.swift b/TwidereSDK/Sources/CoreDataStack/Generated/AutoGenerateRelationship.generated.swift new file mode 100644 index 00000000..29fddaeb --- /dev/null +++ b/TwidereSDK/Sources/CoreDataStack/Generated/AutoGenerateRelationship.generated.swift @@ -0,0 +1,25 @@ +// Generated using Sourcery 1.8.1 — https://github.com/krzysztofzablocki/Sourcery +// DO NOT EDIT + + + + +// sourcery:inline:MastodonStatus.MastodonStatus.AutoGenerateRelationship + +// Generated using Sourcery +// DO NOT EDIT +public struct Relationship { + + public init( + ) { + } +} + +public func configure(relationship: Relationship) { +} + +// sourcery:end + + + + diff --git a/TwidereSDK/Sources/CoreDataStack/Generated/AutoUpdatableObject.generated.swift b/TwidereSDK/Sources/CoreDataStack/Generated/AutoUpdatableObject.generated.swift new file mode 100644 index 00000000..48c23e3d --- /dev/null +++ b/TwidereSDK/Sources/CoreDataStack/Generated/AutoUpdatableObject.generated.swift @@ -0,0 +1,24 @@ +// Generated using Sourcery 1.8.1 — https://github.com/krzysztofzablocki/Sourcery +// DO NOT EDIT + + + + + + + + + +// sourcery:inline:MastodonStatus.MastodonStatus.AutoUpdatableObject + +// Generated using Sourcery +// DO NOT EDIT +// sourcery:end + + + + + + + + diff --git a/TwidereSDK/Sources/CoreDataStack/Utility/ManagedObjectRecord.swift b/TwidereSDK/Sources/CoreDataStack/Utility/ManagedObjectRecord.swift index de4915be..63a361be 100644 --- a/TwidereSDK/Sources/CoreDataStack/Utility/ManagedObjectRecord.swift +++ b/TwidereSDK/Sources/CoreDataStack/Utility/ManagedObjectRecord.swift @@ -18,7 +18,12 @@ public class ManagedObjectRecord: Hashable { } public func object(in managedObjectContext: NSManagedObjectContext) -> T? { - return managedObjectContext.object(with: objectID) as? T + do { + return try managedObjectContext.existingObject(with: objectID) as? T + } catch { + assertionFailure(error.localizedDescription) + return nil + } } public static func == (lhs: ManagedObjectRecord, rhs: ManagedObjectRecord) -> Bool { diff --git a/TwidereSDK/Sources/MastodonSDK/API/Error/Mastodon+API+Error.swift b/TwidereSDK/Sources/MastodonSDK/API/Error/Mastodon+API+Error.swift index 94d063c4..14578821 100644 --- a/TwidereSDK/Sources/MastodonSDK/API/Error/Mastodon+API+Error.swift +++ b/TwidereSDK/Sources/MastodonSDK/API/Error/Mastodon+API+Error.swift @@ -34,3 +34,21 @@ extension Mastodon.API { } } + +extension Mastodon.API.Error: LocalizedError { + public var errorDescription: String? { + guard let mastodonError = mastodonError else { + return httpResponseStatus.reasonPhrase + } + + return mastodonError.errorDescription + } + + public var failureReason: String? { + guard let mastodonError = mastodonError else { + return nil + } + + return mastodonError.failureReason + } +} diff --git a/TwidereSDK/Sources/MastodonSDK/API/Mastodon+API+Status.swift b/TwidereSDK/Sources/MastodonSDK/API/Mastodon+API+Status.swift index 65908b13..7dab087c 100644 --- a/TwidereSDK/Sources/MastodonSDK/API/Mastodon+API+Status.swift +++ b/TwidereSDK/Sources/MastodonSDK/API/Mastodon+API+Status.swift @@ -113,7 +113,6 @@ extension Mastodon.API.Status { } } - extension Mastodon.API.Status { static func statusEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL { @@ -122,6 +121,56 @@ extension Mastodon.API.Status { .appendingPathComponent(statusID) } + /// View a single status + /// + /// Obtain information about a status. + /// + /// - Since: 0.0.0 + /// - Version: 4.1.0 + /// # Last Update + /// 2023/3/28 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `LookupStatusQuery` + /// - authorization: User token + /// - Returns: `Status` nested in the response + public static func lookup( + session: URLSession, + domain: String, + query: LookupStatusQuery, + authorization: Mastodon.API.OAuth.Authorization? + ) async throws -> Mastodon.Response.Content { + let request = Mastodon.API.request( + url: statusEndpointURL(domain: domain, statusID: query.id), + method: .GET, + query: query, + authorization: authorization + ) + let (data, response) = try await session.data(for: request, delegate: nil) + let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + + public struct LookupStatusQuery: JSONEncodeQuery { + + public let id: Mastodon.Entity.Status.ID + + public init( + id: Mastodon.Entity.Status.ID + ) { + self.id = id + } + + var queryItems: [URLQueryItem]? { nil } + } + +} + +extension Mastodon.API.Status { + /// Delete status /// /// Delete one of your own statuses. diff --git a/TwidereSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+History.swift b/TwidereSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+History.swift index 4e3a6640..5374bd08 100644 --- a/TwidereSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+History.swift +++ b/TwidereSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+History.swift @@ -21,5 +21,11 @@ extension Mastodon.Entity { public let day: Date public let uses: String public let accounts: String + + public init(day: Date, uses: String, accounts: String) { + self.day = day + self.uses = uses + self.accounts = accounts + } } } diff --git a/TwidereSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift b/TwidereSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift index 8d8e0d81..5518b6e6 100644 --- a/TwidereSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift +++ b/TwidereSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift @@ -22,6 +22,7 @@ extension Mastodon.Entity { public let url: String public let history: [History]? + enum CodingKeys: String, CodingKey { case name case url diff --git a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Arrows/arrow.ramp.right.imageset/Contents.json b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Arrows/arrow.ramp.right.imageset/Contents.json new file mode 100644 index 00000000..bef6aa4f --- /dev/null +++ b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Arrows/arrow.ramp.right.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "arrow.ramp.right.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Arrows/arrow.ramp.right.imageset/arrow.ramp.right.pdf b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Arrows/arrow.ramp.right.imageset/arrow.ramp.right.pdf new file mode 100644 index 00000000..ab5dddc5 Binary files /dev/null and b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Arrows/arrow.ramp.right.imageset/arrow.ramp.right.pdf differ diff --git a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Arrows/clock.arrow.circlepath.imageset/Contents.json b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Arrows/clock.arrow.circlepath.imageset/Contents.json new file mode 100644 index 00000000..dc0858e9 --- /dev/null +++ b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Arrows/clock.arrow.circlepath.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "clock.arrow.circlepath.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Arrows/clock.arrow.circlepath.imageset/clock.arrow.circlepath.pdf b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Arrows/clock.arrow.circlepath.imageset/clock.arrow.circlepath.pdf new file mode 100644 index 00000000..a849d106 Binary files /dev/null and b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Arrows/clock.arrow.circlepath.imageset/clock.arrow.circlepath.pdf differ diff --git a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Communication/mail.mini.inline.imageset/Contents.json b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Communication/mail.mini.inline.imageset/Contents.json index 4164de67..f8fa0bcc 100644 --- a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Communication/mail.mini.inline.imageset/Contents.json +++ b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Communication/mail.mini.inline.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "mail.mini.inline.pdf", + "filename" : "mail.pdf", "idiom" : "universal" } ], diff --git a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Communication/mail.mini.inline.imageset/mail.mini.inline.pdf b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Communication/mail.mini.inline.imageset/mail.mini.inline.pdf deleted file mode 100644 index 482efe65..00000000 Binary files a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Communication/mail.mini.inline.imageset/mail.mini.inline.pdf and /dev/null differ diff --git a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Communication/mail.mini.inline.imageset/mail.pdf b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Communication/mail.mini.inline.imageset/mail.pdf new file mode 100644 index 00000000..e3f90567 Binary files /dev/null and b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Communication/mail.mini.inline.imageset/mail.pdf differ diff --git a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Communication/text.bubble.imageset/Contents.json b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Communication/text.bubble.imageset/Contents.json new file mode 100644 index 00000000..8b028c9a --- /dev/null +++ b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Communication/text.bubble.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "message.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Communication/text.bubble.imageset/message.pdf b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Communication/text.bubble.imageset/message.pdf new file mode 100644 index 00000000..f9a4bb81 Binary files /dev/null and b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Communication/text.bubble.imageset/message.pdf differ diff --git a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Communication/text.bubble.mini.imageset/Contents.json b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Communication/text.bubble.mini.imageset/Contents.json new file mode 100644 index 00000000..fffdd98b --- /dev/null +++ b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Communication/text.bubble.mini.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "message.mini.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Communication/text.bubble.mini.imageset/message.mini.pdf b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Communication/text.bubble.mini.imageset/message.mini.pdf new file mode 100644 index 00000000..78dfc7cd Binary files /dev/null and b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Communication/text.bubble.mini.imageset/message.mini.pdf differ diff --git a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Media/repeat.lock.imageset/Contents.json b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Media/repeat.lock.imageset/Contents.json new file mode 100644 index 00000000..bcacd54f --- /dev/null +++ b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Media/repeat.lock.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "lock and repeat.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Media/repeat.lock.imageset/lock and repeat.pdf b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Media/repeat.lock.imageset/lock and repeat.pdf new file mode 100644 index 00000000..6b2ad0cb Binary files /dev/null and b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Media/repeat.lock.imageset/lock and repeat.pdf differ diff --git a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Media/repeat.lock.mini.imageset/Contents.json b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Media/repeat.lock.mini.imageset/Contents.json new file mode 100644 index 00000000..bcacd54f --- /dev/null +++ b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Media/repeat.lock.mini.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "lock and repeat.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Media/repeat.lock.mini.imageset/lock and repeat.pdf b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Media/repeat.lock.mini.imageset/lock and repeat.pdf new file mode 100644 index 00000000..f148daea Binary files /dev/null and b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Media/repeat.lock.mini.imageset/lock and repeat.pdf differ diff --git a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Media/repeat.off.imageset/Contents.json b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Media/repeat.off.imageset/Contents.json new file mode 100644 index 00000000..99c347ac --- /dev/null +++ b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Media/repeat.off.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "repeat-off.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Media/repeat.off.imageset/repeat-off.pdf b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Media/repeat.off.imageset/repeat-off.pdf new file mode 100644 index 00000000..de99140d Binary files /dev/null and b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Media/repeat.off.imageset/repeat-off.pdf differ diff --git a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Media/repeat.off.mini.imageset/Contents.json b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Media/repeat.off.mini.imageset/Contents.json new file mode 100644 index 00000000..1fe64dc8 --- /dev/null +++ b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Media/repeat.off.mini.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Retweet-Off.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Media/repeat.off.mini.imageset/Retweet-Off.pdf b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Media/repeat.off.mini.imageset/Retweet-Off.pdf new file mode 100644 index 00000000..a4f1818a Binary files /dev/null and b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Media/repeat.off.mini.imageset/Retweet-Off.pdf differ diff --git a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Object&Tools/globe.mini.inline.imageset/Contents.json b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Object&Tools/globe.mini.inline.imageset/Contents.json index d9d3797a..3e324863 100644 --- a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Object&Tools/globe.mini.inline.imageset/Contents.json +++ b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Object&Tools/globe.mini.inline.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "globe.mini.inline.pdf", + "filename" : "globe.pdf", "idiom" : "universal" } ], diff --git a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Object&Tools/globe.mini.inline.imageset/globe.mini.inline.pdf b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Object&Tools/globe.mini.inline.imageset/globe.pdf similarity index 95% rename from TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Object&Tools/globe.mini.inline.imageset/globe.mini.inline.pdf rename to TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Object&Tools/globe.mini.inline.imageset/globe.pdf index 1cf7abab..6935aa60 100644 Binary files a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Object&Tools/globe.mini.inline.imageset/globe.mini.inline.pdf and b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Object&Tools/globe.mini.inline.imageset/globe.pdf differ diff --git a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Object&Tools/house.imageset/home.pdf b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Object&Tools/house.imageset/home.pdf index c2bb0152..87e49883 100644 Binary files a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Object&Tools/house.imageset/home.pdf and b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Object&Tools/house.imageset/home.pdf differ diff --git a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Object&Tools/house.large.imageset/home.large.pdf b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Object&Tools/house.large.imageset/home.large.pdf index 53dbbe2d..b262c98b 100644 Binary files a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Object&Tools/house.large.imageset/home.large.pdf and b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Object&Tools/house.large.imageset/home.large.pdf differ diff --git a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Object&Tools/lock.mini.inline.imageset/Contents.json b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Object&Tools/lock.mini.inline.imageset/Contents.json index 514008d4..bc5b2098 100644 --- a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Object&Tools/lock.mini.inline.imageset/Contents.json +++ b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Object&Tools/lock.mini.inline.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "lock.mini.inline.pdf", + "filename" : "lock.pdf", "idiom" : "universal" } ], diff --git a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Object&Tools/lock.mini.inline.imageset/lock.mini.inline.pdf b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Object&Tools/lock.mini.inline.imageset/lock.pdf similarity index 95% rename from TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Object&Tools/lock.mini.inline.imageset/lock.mini.inline.pdf rename to TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Object&Tools/lock.mini.inline.imageset/lock.pdf index ac55ddd7..dc68c97f 100644 Binary files a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Object&Tools/lock.mini.inline.imageset/lock.mini.inline.pdf and b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Object&Tools/lock.mini.inline.imageset/lock.pdf differ diff --git a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Object&Tools/lock.open.mini.inline.imageset/Contents.json b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Object&Tools/lock.open.mini.inline.imageset/Contents.json index 34e6c142..7d62252e 100644 --- a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Object&Tools/lock.open.mini.inline.imageset/Contents.json +++ b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Object&Tools/lock.open.mini.inline.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "lock.open.mini.inline.pdf", + "filename" : "lock-open.pdf", "idiom" : "universal" } ], diff --git a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Object&Tools/lock.open.mini.inline.imageset/lock.open.mini.inline.pdf b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Object&Tools/lock.open.mini.inline.imageset/lock-open.pdf similarity index 57% rename from TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Object&Tools/lock.open.mini.inline.imageset/lock.open.mini.inline.pdf rename to TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Object&Tools/lock.open.mini.inline.imageset/lock-open.pdf index ad359c8b..8e0905ba 100644 Binary files a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Object&Tools/lock.open.mini.inline.imageset/lock.open.mini.inline.pdf and b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/Object&Tools/lock.open.mini.inline.imageset/lock-open.pdf differ diff --git a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/TextFormatting/text.quote.imageset/Contents.json b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/TextFormatting/text.quote.imageset/Contents.json new file mode 100644 index 00000000..9eea54a6 --- /dev/null +++ b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/TextFormatting/text.quote.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "blockquote.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/TextFormatting/text.quote.imageset/blockquote.pdf b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/TextFormatting/text.quote.imageset/blockquote.pdf new file mode 100644 index 00000000..3ac1c391 Binary files /dev/null and b/TwidereSDK/Sources/TwidereAsset/Assets.xcassets/TextFormatting/text.quote.imageset/blockquote.pdf differ diff --git a/TwidereSDK/Sources/TwidereAsset/Generated/Assets.swift b/TwidereSDK/Sources/TwidereAsset/Generated/Assets.swift index 514efa9b..1730b999 100644 --- a/TwidereSDK/Sources/TwidereAsset/Generated/Assets.swift +++ b/TwidereSDK/Sources/TwidereAsset/Generated/Assets.swift @@ -8,6 +8,9 @@ #elseif os(tvOS) || os(watchOS) import UIKit #endif +#if canImport(SwiftUI) + import SwiftUI +#endif // Deprecated typealiases @available(*, deprecated, renamed: "ColorAsset.Color", message: "This typealias will be removed in SwiftGen 7.0") @@ -24,11 +27,13 @@ public enum Asset { public static let accentColor = ColorAsset(name: "AccentColor") public enum Arrows { public static let arrowLeft = ImageAsset(name: "Arrows/arrow.left") + public static let arrowRampRight = ImageAsset(name: "Arrows/arrow.ramp.right") public static let arrowRight = ImageAsset(name: "Arrows/arrow.right") public static let arrowTriangle2Circlepath = ImageAsset(name: "Arrows/arrow.triangle.2.circlepath") public static let arrowTurnUpLeft = ImageAsset(name: "Arrows/arrow.turn.up.left") public static let arrowTurnUpLeftMini = ImageAsset(name: "Arrows/arrow.turn.up.left.mini") public static let arrowshapeTurnUpLeftFill = ImageAsset(name: "Arrows/arrowshape.turn.up.left.fill") + public static let clockArrowCirclepath = ImageAsset(name: "Arrows/clock.arrow.circlepath") public static let squareAndArrowUp = ImageAsset(name: "Arrows/square.and.arrow.up") public static let squareAndArrowUpMini = ImageAsset(name: "Arrows/square.and.arrow.up.mini") public static let tablerChevronDown = ImageAsset(name: "Arrows/tabler.chevron.down") @@ -82,6 +87,8 @@ public enum Asset { public static let ellipsisBubblePlus = ImageAsset(name: "Communication/ellipsis.bubble.plus") public static let mail = ImageAsset(name: "Communication/mail") public static let mailMiniInline = ImageAsset(name: "Communication/mail.mini.inline") + public static let textBubble = ImageAsset(name: "Communication/text.bubble") + public static let textBubbleMini = ImageAsset(name: "Communication/text.bubble.mini") public static let textBubbleSmall = ImageAsset(name: "Communication/text.bubble.small") } public enum Editing { @@ -144,7 +151,11 @@ public enum Asset { public static let gifRectangle = ImageAsset(name: "Media/gif.rectangle") public static let playerRectangle = ImageAsset(name: "Media/player.rectangle") public static let `repeat` = ImageAsset(name: "Media/repeat") + public static let repeatLock = ImageAsset(name: "Media/repeat.lock") + public static let repeatLockMini = ImageAsset(name: "Media/repeat.lock.mini") public static let repeatMini = ImageAsset(name: "Media/repeat.mini") + public static let repeatOff = ImageAsset(name: "Media/repeat.off") + public static let repeatOffMini = ImageAsset(name: "Media/repeat.off.mini") } public enum ObjectTools { public static let bell = ImageAsset(name: "Object&Tools/bell") @@ -209,6 +220,7 @@ public enum Asset { public static let capitalFloatLeftLarge = ImageAsset(name: "TextFormatting/capital.float.left.large") public static let listBullet = ImageAsset(name: "TextFormatting/list.bullet") public static let textHeaderRedaction = ImageAsset(name: "TextFormatting/text.header.redaction") + public static let textQuote = ImageAsset(name: "TextFormatting/text.quote") public static let textQuoteMini = ImageAsset(name: "TextFormatting/text.quote.mini") } public enum Transportation { @@ -249,6 +261,13 @@ public final class ColorAsset { } #endif + #if canImport(SwiftUI) + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) + public private(set) lazy var swiftUIColor: SwiftUI.Color = { + SwiftUI.Color(asset: self) + }() + #endif + fileprivate init(name: String) { self.name = name } @@ -268,6 +287,16 @@ public extension ColorAsset.Color { } } +#if canImport(SwiftUI) +@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) +public extension SwiftUI.Color { + init(asset: ColorAsset) { + let bundle = Bundle.module + self.init(asset.name, bundle: bundle) + } +} +#endif + public struct ImageAsset { public fileprivate(set) var name: String @@ -304,6 +333,13 @@ public struct ImageAsset { return result } #endif + + #if canImport(SwiftUI) + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) + public var swiftUIImage: SwiftUI.Image { + SwiftUI.Image(asset: self) + } + #endif } public extension ImageAsset.Image { @@ -321,3 +357,23 @@ public extension ImageAsset.Image { #endif } } + +#if canImport(SwiftUI) +@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) +public extension SwiftUI.Image { + init(asset: ImageAsset) { + let bundle = Bundle.module + self.init(asset.name, bundle: bundle) + } + + init(asset: ImageAsset, label: Text) { + let bundle = Bundle.module + self.init(asset.name, bundle: bundle, label: label) + } + + init(decorative asset: ImageAsset) { + let bundle = Bundle.module + self.init(decorative: asset.name, bundle: bundle) + } +} +#endif diff --git a/TwidereSDK/Sources/TwidereCommon/Extension/NSItemProvider.swift b/TwidereSDK/Sources/TwidereCommon/Extension/NSItemProvider.swift index c6fbff4f..a9ec58a4 100644 --- a/TwidereSDK/Sources/TwidereCommon/Extension/NSItemProvider.swift +++ b/TwidereSDK/Sources/TwidereCommon/Extension/NSItemProvider.swift @@ -99,6 +99,54 @@ extension NSItemProvider { } +extension NSItemProvider { + + public struct GIFLoadResult { + public let data: Data + public let url: URL + public let sizeInBytes: UInt64 + } + + public func loadGIFData() async throws -> GIFLoadResult? { + try await withCheckedThrowingContinuation { continuation in + loadFileRepresentation(forTypeIdentifier: UTType.gif.identifier) { url, error in + if let error = error { + continuation.resume(with: .failure(error)) + return + } + + guard let url = url, + let attribute = try? FileManager.default.attributesOfItem(atPath: url.path), + let sizeInBytes = attribute[.size] as? UInt64 + else { + continuation.resume(with: .success(nil)) + assertionFailure() + return + } + + do { + let fileURL = try FileManager.default.createTemporaryFileURL( + filename: UUID().uuidString, + pathExtension: url.pathExtension + ) + try FileManager.default.copyItem(at: url, to: fileURL) + let data = try Data(contentsOf: fileURL) + let result = GIFLoadResult( + data: data, + url: fileURL, + sizeInBytes: sizeInBytes + ) + + continuation.resume(with: .success(result)) + } catch { + continuation.resume(with: .failure(error)) + } + } // end loadFileRepresentation + } // end try await withCheckedThrowingContinuation + } // end func + +} + extension NSItemProvider { public struct VideoLoadResult { diff --git a/TwidereSDK/Sources/TwidereCommon/Extension/NSLayoutConstraint.swift b/TwidereSDK/Sources/TwidereCommon/Extension/NSLayoutConstraint.swift index 60a169ae..10524dfb 100644 --- a/TwidereSDK/Sources/TwidereCommon/Extension/NSLayoutConstraint.swift +++ b/TwidereSDK/Sources/TwidereCommon/Extension/NSLayoutConstraint.swift @@ -13,3 +13,10 @@ extension NSLayoutConstraint { return self } } + +extension NSLayoutConstraint { + public func identifier(_ identifier: String) -> Self { + self.identifier = identifier + return self + } +} diff --git a/TwidereSDK/Sources/TwidereCommon/Extension/Publisher.swift b/TwidereSDK/Sources/TwidereCommon/Extension/Publisher.swift index 107c13c6..0f75ae93 100644 --- a/TwidereSDK/Sources/TwidereCommon/Extension/Publisher.swift +++ b/TwidereSDK/Sources/TwidereCommon/Extension/Publisher.swift @@ -7,6 +7,43 @@ import Combine +// Ref: https://www.swiftbysundell.com/articles/connecting-async-await-with-other-swift-code/ + +extension Publishers { + public struct MissingOutputError: Error {} +} + +extension Publisher { + public func singleOutput() async throws -> Output { + var cancellable: AnyCancellable? + var didReceiveValue = false + + return try await withCheckedThrowingContinuation { continuation in + cancellable = sink( + receiveCompletion: { completion in + switch completion { + case .failure(let error): + continuation.resume(throwing: error) + case .finished: + if !didReceiveValue { + continuation.resume( + throwing: Publishers.MissingOutputError() + ) + } + } + }, + receiveValue: { value in + guard !didReceiveValue else { return } + + didReceiveValue = true + cancellable?.cancel() + continuation.resume(returning: value) + } + ) + } + } +} + // ref: https://www.swiftbysundell.com/articles/calling-async-functions-within-a-combine-pipeline/ extension Publisher { diff --git a/TwidereSDK/Sources/TwidereCommon/Extension/UIViewController.swift b/TwidereSDK/Sources/TwidereCommon/Extension/UIViewController.swift new file mode 100644 index 00000000..aaa66b0e --- /dev/null +++ b/TwidereSDK/Sources/TwidereCommon/Extension/UIViewController.swift @@ -0,0 +1,47 @@ +// +// UIViewController.swift +// +// +// Created by MainasuK on 2023/3/17. +// + +import UIKit + +extension UIViewController { + + /// Returns the top most view controller from given view controller's stack. + public var topMost: UIViewController? { + // presented view controller + if let presentedViewController = presentedViewController { + return presentedViewController.topMost + } + + // UITabBarController + if let tabBarController = self as? UITabBarController, + let selectedViewController = tabBarController.selectedViewController { + return selectedViewController.topMost + } + + // UINavigationController + if let navigationController = self as? UINavigationController, + let visibleViewController = navigationController.visibleViewController { + return visibleViewController.topMost + } + + // UIPageController + if let pageViewController = self as? UIPageViewController, + pageViewController.viewControllers?.count == 1 { + return pageViewController.viewControllers?.first?.topMost ?? self + } + + // child view controller + for subview in self.view?.subviews ?? [] { + if let childViewController = subview.next as? UIViewController { + return childViewController.topMost + } + } + + return self + } + +} diff --git a/TwidereSDK/Sources/TwidereCommon/Preference/Preference+Appearance.swift b/TwidereSDK/Sources/TwidereCommon/Preference/Preference+Appearance.swift index 305ca4c9..8d0dc401 100644 --- a/TwidereSDK/Sources/TwidereCommon/Preference/Preference+Appearance.swift +++ b/TwidereSDK/Sources/TwidereCommon/Preference/Preference+Appearance.swift @@ -23,12 +23,10 @@ extension UserDefaults { // Translate button - @objc public enum TranslateButtonPreference: Int, Identifiable, CaseIterable { + @objc public enum TranslateButtonPreference: Int, CaseIterable { case auto case always case off - - public var id: String { "\(rawValue)" } } @objc dynamic public var translateButtonPreference: TranslateButtonPreference { @@ -41,12 +39,10 @@ extension UserDefaults { // Service - @objc public enum TranslationServicePreference: Int, Identifiable, CaseIterable { + @objc public enum TranslationServicePreference: Int, CaseIterable { case bing case deepl case google - - public var id: String { "\(rawValue)" } } @objc dynamic public var translationServicePreference: TranslationServicePreference { diff --git a/TwidereSDK/Sources/TwidereCommon/Preference/Preference+Behaviors.swift b/TwidereSDK/Sources/TwidereCommon/Preference/Preference+Behaviors.swift new file mode 100644 index 00000000..a117fb12 --- /dev/null +++ b/TwidereSDK/Sources/TwidereCommon/Preference/Preference+Behaviors.swift @@ -0,0 +1,87 @@ +// +// Preference+Behaviors.swift +// +// +// Created by MainasuK on 2022-7-27. +// + +import Foundation + +// MARK: - Tab bar: label +extension UserDefaults { + + @objc dynamic public var preferredTabBarLabelDisplay: Bool { + get { return bool(forKey: #function) } + set { self[#function] = newValue } + } + +} + +// MARK: - Tab bar: Tap Scroll +extension UserDefaults { + + @objc public enum TabBarTapScrollPreference: Int, Hashable, CaseIterable { + case single + case double + } + + @objc dynamic public var tabBarTapScrollPreference: TabBarTapScrollPreference { + get { + guard let rawValue: Int = self[#function] else { return .single } + return TabBarTapScrollPreference(rawValue: rawValue) ?? .single + } + set { self[#function] = newValue.rawValue } + } + +} + +// MARK: - Tab bar: Timeline Refreshing +extension UserDefaults { + + @objc dynamic public var preferredTimelineAutoRefresh: Bool { + get { + return bool(forKey: #function) + } + set { self[#function] = newValue } + } + + @objc public enum TimelineRefreshInterval: Int, Hashable, CaseIterable { + case _60s + case _120s + case _300s + + public var seconds: TimeInterval { + switch self { + case ._60s: return 60 + case ._120s: return 120 + case ._300s: return 300 + } + } + } + + @objc dynamic public var timelineRefreshInterval: TimelineRefreshInterval { + get { + guard let rawValue: Int = self[#function] else { return ._60s } + return TimelineRefreshInterval(rawValue: rawValue) ?? ._60s + } + set { self[#function] = newValue.rawValue } + } + + @objc dynamic public var preferredTimelineResetToTop: Bool { + get { + return bool(forKey: #function) + } + set { self[#function] = newValue } + } + +} + +// MARK: - History +extension UserDefaults { + + @objc dynamic public var preferredEnableHistory: Bool { + get { return bool(forKey: #function) } + set { self[#function] = newValue } + } + +} diff --git a/TwidereSDK/Sources/TwidereCommon/Preference/Preference+Display.swift b/TwidereSDK/Sources/TwidereCommon/Preference/Preference+Display.swift index 918d8aea..b5c015ad 100644 --- a/TwidereSDK/Sources/TwidereCommon/Preference/Preference+Display.swift +++ b/TwidereSDK/Sources/TwidereCommon/Preference/Preference+Display.swift @@ -1,6 +1,6 @@ // // Preference+Display.swift -// AppShared +// TwidereCommon // // Created by Cirno MainasuK on 2021-11-3. // Copyright © 2021 Twidere. All rights reserved. diff --git a/TwidereSDK/Sources/TwidereCore/Error/AppError.swift b/TwidereSDK/Sources/TwidereCore/Error/AppError.swift index 0b44955c..ded4d7e1 100644 --- a/TwidereSDK/Sources/TwidereCore/Error/AppError.swift +++ b/TwidereSDK/Sources/TwidereCore/Error/AppError.swift @@ -55,7 +55,7 @@ extension AppError.ErrorReason: LocalizedError { public var errorDescription: String? { switch self { case .internal(let reason): - return "Internal" + return "Internal. \(reason)" case .twitterInternalError(let error): return error.errorDescription case .authenticationMissing: @@ -63,7 +63,7 @@ extension AppError.ErrorReason: LocalizedError { case .badRequest: return "Bad Request" case .requestThrottle: - return "Request Throttle" + return L10n.Common.Alerts.RequestThrottle.title case .twitterResponseError(let error): guard let twitterAPIError = error.twitterAPIError else { return error.httpResponseStatus.reasonPhrase @@ -71,11 +71,7 @@ extension AppError.ErrorReason: LocalizedError { return twitterAPIError.errorDescription case .mastodonResponseError(let error): - guard let mastodonError = error.mastodonError else { - return error.httpResponseStatus.reasonPhrase - } - - return mastodonError.errorDescription + return error.errorDescription } } @@ -90,7 +86,7 @@ extension AppError.ErrorReason: LocalizedError { case .badRequest: return "The request is invalid" case .requestThrottle: - return "The requests are too frequent" + return L10n.Common.Alerts.RequestThrottle.message case .twitterResponseError(let error): guard let twitterAPIError = error.twitterAPIError else { return nil @@ -98,11 +94,7 @@ extension AppError.ErrorReason: LocalizedError { return twitterAPIError.failureReason case .mastodonResponseError(let error): - guard let mastodonError = error.mastodonError else { - return nil - } - - return mastodonError.failureReason + return error.failureReason } } diff --git a/TwidereSDK/Sources/TwidereCore/Error/EmptyState.swift b/TwidereSDK/Sources/TwidereCore/Error/EmptyState.swift new file mode 100644 index 00000000..df922252 --- /dev/null +++ b/TwidereSDK/Sources/TwidereCore/Error/EmptyState.swift @@ -0,0 +1,50 @@ +// +// EmptyState.swift +// +// +// Created by MainasuK on 2023-06-20. +// + +import Foundation +import TwidereLocalization + +public enum EmptyState: Swift.Error { + case noResults + case unableToAccess(reason: String? = nil) + case homeListNotSelected +} + +extension EmptyState { + public var iconSystemName: String { + switch self { + case .noResults: + return "eye.slash" + case .unableToAccess: + return "exclamationmark.triangle" + case .homeListNotSelected: + return "list.bullet" + } + } + + public var title: String { + switch self { + case .noResults: + return L10n.Common.Controls.List.noResults + case .unableToAccess: + return "Unable to access" + case .homeListNotSelected: + return "No list selected" + } + } + + public var subtitle: String? { + switch self { + case .noResults: + return nil + case .unableToAccess(let reason): + return reason + case .homeListNotSelected: + return "Please select a list to continue browsing. The home timeline is no longer available due to API changes." + } + } +} diff --git a/TwidereSDK/Sources/TwidereCore/Error/Twitter/TwitterAPIError.swift b/TwidereSDK/Sources/TwidereCore/Error/Twitter/TwitterAPIError.swift index dd4d6123..1bc90779 100644 --- a/TwidereSDK/Sources/TwidereCore/Error/Twitter/TwitterAPIError.swift +++ b/TwidereSDK/Sources/TwidereCore/Error/Twitter/TwitterAPIError.swift @@ -13,6 +13,8 @@ extension Twitter.API.Error.TwitterAPIError: LocalizedError { public var errorDescription: String? { switch self { + case .notAuthorizedToViewTheSpecifiedUser: + return L10n.Common.Alerts.PermissionDeniedNotAuthorized.title case .userHasBeenSuspended: return L10n.Common.Alerts.AccountSuspended.title case .rateLimitExceeded: @@ -32,6 +34,8 @@ extension Twitter.API.Error.TwitterAPIError: LocalizedError { public var failureReason: String? { switch self { + case .notAuthorizedToViewTheSpecifiedUser: + return L10n.Common.Alerts.PermissionDeniedNotAuthorized.message case .userHasBeenSuspended: let twitterRules = L10n.Common.Alerts.AccountSuspended.twitterRules return L10n.Common.Alerts.AccountSuspended.message(twitterRules) diff --git a/TwidereSDK/Sources/TwidereCore/Extension/CoreDataStack/Feed.swift b/TwidereSDK/Sources/TwidereCore/Extension/CoreDataStack/Feed.swift index 75c9f7bf..3486c59b 100644 --- a/TwidereSDK/Sources/TwidereCore/Extension/CoreDataStack/Feed.swift +++ b/TwidereSDK/Sources/TwidereCore/Extension/CoreDataStack/Feed.swift @@ -11,47 +11,12 @@ import CoreDataStack extension Feed { public enum Content { - case twitter(TwitterStatus) - case mastodon(MastodonStatus) - case mastodonNotification(MastodonNotification) - case none - } - - public var content: Content { - switch kind { - case .home: - if let status = twitterStatus { - return .twitter(status) - } else if let status = mastodonStatus { - return .mastodon(status) - } else { - return .none - } - case .notificationAll, .notificationMentions: - if let status = twitterStatus { - return .twitter(status) - } else if let status = mastodonStatus { - assertionFailure("The status should nest in mastodonNotification") - return .mastodon(status) - } else if let notification = mastodonNotification { - return .mastodonNotification(notification) - } else { - return .none - } - case .none: - return .none - } - } -} - -extension Feed { - public enum ObjectContent { case status(StatusObject) case notification(NotificationObject) case none } - public var objectContent: ObjectContent { + public var content: Content { switch kind { case .home: if let status = twitterStatus { @@ -63,16 +28,9 @@ extension Feed { } case .notificationAll, .notificationMentions: if let status = twitterStatus { - return .status(.twitter(object: status)) - } else if let status = mastodonStatus { - assertionFailure("The status should nest in mastodonNotification") - return .status(.mastodon(object: status)) + return .notification(.twitter(object: status)) } else if let notification = mastodonNotification { - if let status = notification.status { - return .status(.mastodon(object: status)) - } else { - return .notification(.mastodon(object: notification)) - } + return .notification(.mastodon(object: notification)) } else { return .none } @@ -80,39 +38,4 @@ extension Feed { return .none } } - -} - -extension Feed { - public var statusObject: StatusObject? { - switch acct { - case .none: - return nil - case .twitter: - guard let status = twitterStatus else { return nil } - return .twitter(object: status) - case .mastodon: - if let status = mastodonStatus { - return .mastodon(object: status) - } else if let notification = mastodonNotification, - let status = notification.status { - return .mastodon(object: status) - } else { - return nil - } - } - } - - @available(*, deprecated, message: "") - public var notificationObject: NotificationObject? { - switch acct { - case .none: - return nil - case .twitter: - return nil - case .mastodon: - guard let notification = mastodonNotification else { return nil } - return .mastodon(object: notification) - } - } } diff --git a/TwidereSDK/Sources/TwidereCore/Extension/CoreDataStack/History.swift b/TwidereSDK/Sources/TwidereCore/Extension/CoreDataStack/History.swift new file mode 100644 index 00000000..e0da785e --- /dev/null +++ b/TwidereSDK/Sources/TwidereCore/Extension/CoreDataStack/History.swift @@ -0,0 +1,35 @@ +// +// History.swift +// +// +// Created by MainasuK on 2022-7-29. +// + +import Foundation +import CoreDataStack + +extension History { + + public var statusObject: StatusObject? { + if let status = twitterStatus { + return .twitter(object: status) + } + if let status = mastodonStatus { + return .mastodon(object: status) + } + + return nil + } + + public var userObject: UserObject? { + if let user = twitterUser { + return .twitter(object: user) + } + if let user = mastodonUser { + return .mastodon(object: user) + } + + return nil + } + +} diff --git a/TwidereSDK/Sources/TwidereCore/Extension/CoreDataStack/MastodonUser.swift b/TwidereSDK/Sources/TwidereCore/Extension/CoreDataStack/MastodonUser.swift index 1da6c9ca..383a4af6 100644 --- a/TwidereSDK/Sources/TwidereCore/Extension/CoreDataStack/MastodonUser.swift +++ b/TwidereSDK/Sources/TwidereCore/Extension/CoreDataStack/MastodonUser.swift @@ -32,7 +32,7 @@ extension MastodonUser { extension MastodonUser { public var nameMetaContent: MastodonMetaContent? { do { - let content = MastodonContent(content: name, emojis: emojis.asDictionary) + let content = MastodonContent(content: name, emojis: emojisTransient.asDictionary) let metaContent = try MastodonMetaContent.convert(document: content) return metaContent } catch { @@ -44,7 +44,7 @@ extension MastodonUser { public var bioMetaContent: MastodonMetaContent? { guard let note = note else { return nil } do { - let content = MastodonContent(content: note, emojis: emojis.asDictionary) + let content = MastodonContent(content: note, emojis: emojisTransient.asDictionary) let metaContent = try MastodonMetaContent.convert(document: content) return metaContent } catch { diff --git a/TwidereSDK/Sources/TwidereCore/Extension/CoreDataStack/MastodonVisibility.swift b/TwidereSDK/Sources/TwidereCore/Extension/CoreDataStack/MastodonVisibility.swift deleted file mode 100644 index d7691a0b..00000000 --- a/TwidereSDK/Sources/TwidereCore/Extension/CoreDataStack/MastodonVisibility.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// MastodonVisibility.swift -// -// -// Created by MainasuK on 2021-12-6. -// - -import Foundation -import CoreDataStack -import MastodonSDK - -extension MastodonVisibility { - public var asStatusVisibility: StatusVisibility { - let visibility: Mastodon.Entity.Status.Visibility = .init(rawValue: rawValue) ?? ._other(rawValue) - return .mastodon(visibility) - } -} diff --git a/TwidereSDK/Sources/TwidereCore/Extension/CoreDataStack/TwitterStatus.swift b/TwidereSDK/Sources/TwidereCore/Extension/CoreDataStack/TwitterStatus.swift index ab51cb3b..4728b3a1 100644 --- a/TwidereSDK/Sources/TwidereCore/Extension/CoreDataStack/TwitterStatus.swift +++ b/TwidereSDK/Sources/TwidereCore/Extension/CoreDataStack/TwitterStatus.swift @@ -7,6 +7,7 @@ import Foundation import CoreDataStack +import TwitterMeta extension TwitterStatus { public var statusURL: URL { @@ -15,29 +16,52 @@ extension TwitterStatus { public var displayText: String { var text = self.text - for url in entities?.urls ?? [] { + for url in entitiesTransient?.urls ?? [] { let shortURL = url.url guard let displayURL = url.displayURL, let expandedURL = url.expandedURL else { continue } + // drop media URL guard !displayURL.hasPrefix("pic.twitter.com") else { text = text.replacingOccurrences(of: shortURL, with: "") continue } - if let quote = quote { - let quoteID = quote.id - guard !displayURL.hasPrefix("twitter.com"), - !expandedURL.hasPrefix(quoteID) - else { - text = text.replacingOccurrences(of: shortURL, with: "") - continue + // drop twitter URL + // - quote URL: remove URL + // - long tweet self URL suffix: replace "… URL" with "…" + if displayURL.hasPrefix("twitter.com") && expandedURL.localizedCaseInsensitiveContains("/status/") { + if expandedURL.localizedCaseInsensitiveContains(self.id) { + text = text.replacingOccurrences(of: "… " + shortURL, with: "…") } + text = text.replacingOccurrences(of: shortURL, with: "") + continue } - - text = text.replacingOccurrences(of: shortURL, with: expandedURL) } return text } + + public var urlEntities: [TwitterContent.URLEntity] { + let results = entitiesTransient?.urls?.map { entity in + TwitterContent.URLEntity(url: entity.url, expandedURL: entity.expandedURL, displayURL: entity.displayURL) + } + return results ?? [] + } +} + +extension TwitterStatus { + /// The tweet more then 240 characters + public var hasMore: Bool { + for url in entitiesTransient?.urls ?? [] { + guard text.localizedCaseInsensitiveContains("… " + url.url) else { continue } + guard let expandedURL = url.expandedURL else { continue } + guard expandedURL.hasPrefix("https://twitter.com/") else { continue } + guard expandedURL.localizedCaseInsensitiveContains("status") else { continue } + guard expandedURL.hasSuffix(self.id) else { continue } + return true + } + + return false + } } diff --git a/TwidereSDK/Sources/TwidereCore/Extension/CoreDataStack/TwitterUser.swift b/TwidereSDK/Sources/TwidereCore/Extension/CoreDataStack/TwitterUser.swift index 1125d265..5831ef39 100644 --- a/TwidereSDK/Sources/TwidereCore/Extension/CoreDataStack/TwitterUser.swift +++ b/TwidereSDK/Sources/TwidereCore/Extension/CoreDataStack/TwitterUser.swift @@ -51,7 +51,6 @@ extension TwitterUser { } extension TwitterUser { - public enum SizeKind: String { case small case medium @@ -80,52 +79,48 @@ extension String { } extension TwitterUser { - public func bioMetaContent(provider: TwitterTextProvider) -> TwitterMetaContent? { - let _bioContent: String? = bio.flatMap { text in - var text = text - for url in bioEntities?.urls ?? [] { - guard let expandedURL = url.expandedURL else { continue } - let shortURL = url.url - text = text.replacingOccurrences(of: shortURL, with: expandedURL) - } - return text + public var bioURLEntities: [TwitterContent.URLEntity] { + let results = bioEntitiesTransient?.urls?.map { entity in + TwitterContent.URLEntity(url: entity.url, expandedURL: entity.expandedURL, displayURL: entity.displayURL) } - guard let bioContent = _bioContent else { return nil } - let content = TwitterContent(content: bioContent) + return results ?? [] + } + + public func bioMetaContent(provider: TwitterTextProvider) -> TwitterMetaContent? { + guard let bio = self.bio else { return nil } + let content = TwitterContent(content: bio, urlEntities: bioURLEntities) let metaContent = TwitterMetaContent.convert( - content: content, - urlMaximumLength: 50, + document: content, + urlMaximumLength: .max, twitterTextProvider: provider ) return metaContent } - public func urlMetaContent(provider: TwitterTextProvider) -> TwitterMetaContent? { - let _urlContent: String? = url.flatMap { text in - var text = text - for url in urlEntities?.urls ?? [] { - guard let expandedURL = url.expandedURL else { continue } - let shortURL = url.url - text = text.replacingOccurrences(of: shortURL, with: expandedURL) - } - return text + public var urlEntity: [TwitterContent.URLEntity] { + let results = urlEntitiesTransient?.urls?.map { entity in + TwitterContent.URLEntity(url: entity.url, expandedURL: entity.expandedURL, displayURL: entity.displayURL) } - guard let urlContent = _urlContent else { return nil } - let content = TwitterContent(content: urlContent) + return results ?? [] + } + + public func urlMetaContent(provider: TwitterTextProvider) -> TwitterMetaContent? { + guard let url = self.url else { return nil } + let content = TwitterContent(content: url, urlEntities: urlEntity) let metaContent = TwitterMetaContent.convert( - content: content, - urlMaximumLength: 50, + document: content, + urlMaximumLength: .max, twitterTextProvider: provider ) return metaContent } public func locationMetaContent(provider: TwitterTextProvider) -> TwitterMetaContent? { - return location.flatMap { location in + return location.flatMap { location -> TwitterMetaContent? in let location = location.trimmingCharacters(in: .whitespacesAndNewlines) guard !location.isEmpty else { return nil } let metaContent = TwitterMetaContent.convert( - content: TwitterContent(content: location), + document: TwitterContent(content: location, urlEntities: []), urlMaximumLength: 50, twitterTextProvider: provider ) diff --git a/TwidereSDK/Sources/TwidereCore/Extension/MetaTextKit/MetaContent.swift b/TwidereSDK/Sources/TwidereCore/Extension/MetaTextKit/MetaContent.swift index 3321cf52..3dff2ad3 100644 --- a/TwidereSDK/Sources/TwidereCore/Extension/MetaTextKit/MetaContent.swift +++ b/TwidereSDK/Sources/TwidereCore/Extension/MetaTextKit/MetaContent.swift @@ -14,28 +14,26 @@ import Meta extension Meta { public enum Source { case plaintext(string: String) - case twitter(string: String, urlMaximumLength: Int = 30, provider: TwitterTextProvider) - case mastodon(string: String, emojis: MastodonContent.Emojis) + case twitter(content: TwitterContent, urlMaximumLength: Int = 30) + case mastodon(content: MastodonContent) } - public static func convert(from source: Source) -> MetaContent { + public static func convert(document source: Source) -> MetaContent { switch source { case .plaintext(let string): return PlaintextMetaContent(string: string) - case .twitter(let string, let urlMaximumLength, let provider): - let content = TwitterContent(content: string) + case .twitter(let content, let urlMaximumLength): return TwitterMetaContent.convert( - content: content, + document: content, urlMaximumLength: urlMaximumLength, - twitterTextProvider: provider + twitterTextProvider: SwiftTwitterTextProvider() ) - case .mastodon(let string, let emojis): + case .mastodon(let content): do { - let content = MastodonContent(content: string, emojis: emojis) return try MastodonMetaContent.convert(document: content) } catch { assertionFailure() - return PlaintextMetaContent(string: string) + return PlaintextMetaContent(string: content.content) } } } diff --git a/TwidereSDK/Sources/TwidereCore/Extension/MetaTextKit/PlaintextMetaContent.swift b/TwidereSDK/Sources/TwidereCore/Extension/MetaTextKit/PlaintextMetaContent.swift deleted file mode 100644 index ed62cf37..00000000 --- a/TwidereSDK/Sources/TwidereCore/Extension/MetaTextKit/PlaintextMetaContent.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// PlaintextMetaContent.swift -// PlaintextMetaContent -// -// Created by Cirno MainasuK on 2021-8-19. -// Copyright © 2021 Twidere. All rights reserved. -// - -import Foundation -import Meta - -public struct PlaintextMetaContent: MetaContent { - public let string: String - public let entities: [Meta.Entity] = [] - - public init(string: String) { - self.string = string - } - - public func metaAttachment(for entity: Meta.Entity) -> MetaAttachment? { - return nil - } -} diff --git a/TwidereSDK/Sources/TwidereCore/Extension/Preference/Preference+Appearance.swift b/TwidereSDK/Sources/TwidereCore/Extension/Preference/Preference+Appearance.swift index 66927422..0dcb88f7 100644 --- a/TwidereSDK/Sources/TwidereCore/Extension/Preference/Preference+Appearance.swift +++ b/TwidereSDK/Sources/TwidereCore/Extension/Preference/Preference+Appearance.swift @@ -6,7 +6,6 @@ // import Foundation -import TwidereCommon import TwidereLocalization extension UserDefaults.TranslateButtonPreference { diff --git a/TwidereSDK/Sources/TwidereCore/Extension/TwitterSDK/Twitter+Entity+V2+Tweet.swift b/TwidereSDK/Sources/TwidereCore/Extension/TwitterSDK/Twitter+Entity+V2+Tweet.swift new file mode 100644 index 00000000..dff3e777 --- /dev/null +++ b/TwidereSDK/Sources/TwidereCore/Extension/TwitterSDK/Twitter+Entity+V2+Tweet.swift @@ -0,0 +1,19 @@ +// +// Twitter+Entity+V2+Tweet.swift +// +// +// Created by MainasuK on 2023/6/2. +// + +import Foundation +import TwitterSDK +import TwitterMeta + +extension Twitter.Entity.V2.Tweet { + public var urlEntities: [TwitterContent.URLEntity] { + let results = entities?.urls?.map { entity in + TwitterContent.URLEntity(url: entity.url, expandedURL: entity.expandedURL, displayURL: entity.displayURL) + } + return results ?? [] + } +} diff --git a/TwidereSDK/Sources/TwidereCore/FetchedResultsController/History/HistoryFetchedResultsController.swift b/TwidereSDK/Sources/TwidereCore/FetchedResultsController/History/HistoryFetchedResultsController.swift new file mode 100644 index 00000000..4bcab913 --- /dev/null +++ b/TwidereSDK/Sources/TwidereCore/FetchedResultsController/History/HistoryFetchedResultsController.swift @@ -0,0 +1,91 @@ +// +// HistoryFetchedResultsController.swift +// +// +// Created by MainasuK on 2022-7-29. +// + +import os.log +import Foundation +import UIKit +import Combine +import CoreData +import CoreDataStack +import TwitterSDK +import OrderedCollections + +final public class HistoryFetchedResultsController: NSObject { + + public let logger = Logger(subsystem: "StatusHistoryFetchedResultsController", category: "DB") + + var disposeBag = Set() + + public let fetchedResultsController: NSFetchedResultsController + + // input + @Published public var predicate: NSPredicate + + // output + @Published public var groupedRecords: [(String, [ManagedObjectRecord])] = [] + + public init(managedObjectContext: NSManagedObjectContext) { + self.fetchedResultsController = { + let fetchRequest = History.sortedFetchRequest + // make sure initial query return empty results + fetchRequest.predicate = History.predicate(acct: .none) + fetchRequest.returnsObjectsAsFaults = false + fetchRequest.shouldRefreshRefetchedObjects = true + fetchRequest.fetchBatchSize = 15 + let controller = NSFetchedResultsController( + fetchRequest: fetchRequest, + managedObjectContext: managedObjectContext, + sectionNameKeyPath: #keyPath(History.sectionIdentifierByDay), + cacheName: nil + ) + + return controller + }() + self.predicate = History.predicate(acct: .none) + super.init() + + fetchedResultsController.delegate = self + + $predicate.removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] predicate in + guard let self = self else { return } + self.fetchedResultsController.fetchRequest.predicate = predicate + do { + try self.fetchedResultsController.performFetch() + } catch { + assertionFailure(error.localizedDescription) + } + } + .store(in: &disposeBag) + } + + deinit { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + } + +} + +// MARK: - NSFetchedResultsControllerDelegate +extension HistoryFetchedResultsController: NSFetchedResultsControllerDelegate { + public func controller( + _ controller: NSFetchedResultsController, + didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference + ) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + var groupedRecords: [(String, [ManagedObjectRecord])] = [] + for sectionInfo in controller.sections ?? [] { + guard let objects = sectionInfo.objects as? [History] else { return } + let identifier = sectionInfo.name + let records = objects.map { $0.asRecrod } + groupedRecords.append((identifier, records)) + } + self.groupedRecords = groupedRecords + } +} + diff --git a/TwidereSDK/Sources/TwidereCore/FetchedResultsController/List/MastodonListRecordFetchedResultController.swift b/TwidereSDK/Sources/TwidereCore/FetchedResultsController/List/MastodonListRecordFetchedResultController.swift index 6fc0330d..3db99d84 100644 --- a/TwidereSDK/Sources/TwidereCore/FetchedResultsController/List/MastodonListRecordFetchedResultController.swift +++ b/TwidereSDK/Sources/TwidereCore/FetchedResultsController/List/MastodonListRecordFetchedResultController.swift @@ -92,6 +92,10 @@ extension MastodonListRecordFetchedResultController { ids = [] } + public func update(ids: [TwitterList.ID]) { + self.ids = ids + } + public func prepend(ids: [TwitterList.ID]) { var result = self.ids let ids = ids.filter { !result.contains($0) } diff --git a/TwidereSDK/Sources/TwidereCore/FetchedResultsController/List/TwitterListRecordFetchedResultController.swift b/TwidereSDK/Sources/TwidereCore/FetchedResultsController/List/TwitterListRecordFetchedResultController.swift index eb20db53..3ee32729 100644 --- a/TwidereSDK/Sources/TwidereCore/FetchedResultsController/List/TwitterListRecordFetchedResultController.swift +++ b/TwidereSDK/Sources/TwidereCore/FetchedResultsController/List/TwitterListRecordFetchedResultController.swift @@ -89,6 +89,10 @@ extension TwitterListRecordFetchedResultController { ids = [] } + public func update(ids: [TwitterList.ID]) { + self.ids = ids + } + public func prepend(ids: [TwitterList.ID]) { var result = self.ids let ids = ids.filter { !result.contains($0) } diff --git a/TwidereSDK/Sources/TwidereCore/FetchedResultsController/Status/StatusRecordFetchedResultController.swift b/TwidereSDK/Sources/TwidereCore/FetchedResultsController/Status/StatusRecordFetchedResultController.swift index d3f0d0ba..11190c00 100644 --- a/TwidereSDK/Sources/TwidereCore/FetchedResultsController/Status/StatusRecordFetchedResultController.swift +++ b/TwidereSDK/Sources/TwidereCore/FetchedResultsController/Status/StatusRecordFetchedResultController.swift @@ -68,4 +68,8 @@ extension StatusRecordFetchedResultController { twitterStatusFetchedResultController.statusIDs.value = [] mastodonStatusFetchedResultController.statusIDs.value = [] } + + public func reload() { + self.records = self.records + } } diff --git a/TwidereSDK/Sources/TwidereCore/FetchedResultsController/Status/TwitterStatusFetchedResultController.swift b/TwidereSDK/Sources/TwidereCore/FetchedResultsController/Status/TwitterStatusFetchedResultController.swift index 5f6e033b..5568530b 100644 --- a/TwidereSDK/Sources/TwidereCore/FetchedResultsController/Status/TwitterStatusFetchedResultController.swift +++ b/TwidereSDK/Sources/TwidereCore/FetchedResultsController/Status/TwitterStatusFetchedResultController.swift @@ -103,7 +103,7 @@ extension TwitterStatusFetchedResultController { } self.statusIDs.value = result } - + } // MARK: - NSFetchedResultsControllerDelegate diff --git a/TwidereSDK/Sources/TwidereCore/Model/Authentication/AuthenticationContext+TwitterRateLimit.swift b/TwidereSDK/Sources/TwidereCore/Model/Authentication/AuthenticationContext+TwitterRateLimit.swift new file mode 100644 index 00000000..3cc2a5b5 --- /dev/null +++ b/TwidereSDK/Sources/TwidereCore/Model/Authentication/AuthenticationContext+TwitterRateLimit.swift @@ -0,0 +1,75 @@ +// +// AuthenticationContext+TwitterRateLimit.swift +// +// +// Created by MainasuK on 2022-8-12. +// + +import Foundation +import TwidereCommon + +extension AuthenticationContext { + + /// Client side rate limit + public struct RateLimit: Codable { + public var remaining: Int + public let limit: Int + public let reset: Date + + public init(limit: Int, remaining: Int, reset: Date) { + self.limit = limit + self.remaining = remaining + self.reset = reset + } + + /// Rate limit scope + public enum Scope: String, Hashable { + /// Post publish endpoint + case publish + } + + } + + private func key(scope: RateLimit.Scope) -> String { + return "com.twidere.TwidereCore.TwitterRateLimit.\(scope.rawValue).\(acct.rawValue)" + } + + public func rateLimit(scope: RateLimit.Scope) -> RateLimit? { + let key = key(scope: scope) + guard let encoded = AppSecret.keychain[key], + let data = Data(base64Encoded: encoded), + let rateLimit = try? JSONDecoder().decode(AuthenticationContext.RateLimit.self, from: data) + else { + return nil + } + + return rateLimit + } + + @discardableResult + public func updateRateLimit(scope: RateLimit.Scope, now: Date) -> RateLimit { + if var rateLimit = rateLimit(scope: scope), now < rateLimit.reset { + rateLimit.remaining = max(0, rateLimit.remaining - 1) + return rateLimit + } else { + let reset = Calendar.current.date(byAdding: .minute, value: 10, to: now) ?? now.addingTimeInterval(10 * 60) // 10 min + + let limit = 5 + let rateLimit = RateLimit( + limit: limit, + remaining: limit - 1, + reset: reset + ) + + let key = key(scope: scope) + AppSecret.keychain[key] = { + guard let data = try? JSONEncoder().encode(rateLimit) else { return nil } + let encoded = data.base64EncodedString() + return encoded + }() + + return rateLimit + } + } + +} diff --git a/TwidereSDK/Sources/TwidereCore/Model/Authentication/AuthenticationContext.swift b/TwidereSDK/Sources/TwidereCore/Model/Authentication/AuthenticationContext.swift index 847c4815..f9869868 100644 --- a/TwidereSDK/Sources/TwidereCore/Model/Authentication/AuthenticationContext.swift +++ b/TwidereSDK/Sources/TwidereCore/Model/Authentication/AuthenticationContext.swift @@ -57,6 +57,15 @@ extension AuthenticationContext { .flatMap { UserObject.mastodon(object: $0.user) } } } + + public func authenticationIndex(in managedObjectContext: NSManagedObjectContext) -> AuthenticationIndex? { + switch self { + case .twitter(let authenticationContext): + return authenticationContext.authenticationRecord.object(in: managedObjectContext)?.authenticationIndex + case .mastodon(let authenticationContext): + return authenticationContext.authenticationRecord.object(in: managedObjectContext)?.authenticationIndex + } + } } extension AuthenticationContext { @@ -68,6 +77,24 @@ extension AuthenticationContext { return .mastodon(.init(domain: authenticationContext.domain, id: authenticationContext.userID)) } } + + public var acct: Feed.Acct { + switch self { + case .twitter(let authenticationContext): + return .twitter(userID: authenticationContext.userID) + case .mastodon(let authenticationContext): + return .mastodon(domain: authenticationContext.domain, userID: authenticationContext.userID) + } + } +} + +extension AuthenticationContext { + public var platform: Platform { + switch self { + case .twitter: return .twitter + case .mastodon: return .mastodon + } + } } public struct TwitterAuthenticationContext: Hashable { diff --git a/TwidereX/Diffable/Misc/Hashtag/HashtagData.swift b/TwidereSDK/Sources/TwidereCore/Model/Hashtag/HashtagData.swift similarity index 92% rename from TwidereX/Diffable/Misc/Hashtag/HashtagData.swift rename to TwidereSDK/Sources/TwidereCore/Model/Hashtag/HashtagData.swift index c81cf27f..e8759410 100644 --- a/TwidereX/Diffable/Misc/Hashtag/HashtagData.swift +++ b/TwidereSDK/Sources/TwidereCore/Model/Hashtag/HashtagData.swift @@ -13,7 +13,7 @@ import MastodonSDK // Maybe configure an in-memory CoreData persist coordinator better here // But a simple solution should also works -enum HashtagData: Hashable { +public enum HashtagData: Hashable { // case twitter(record: ManagedObjectRecord) case mastodon(data: Mastodon.Entity.Tag) } diff --git a/TwidereSDK/Sources/TwidereCore/Model/Notification/NotificationHeaderInfo.swift b/TwidereSDK/Sources/TwidereCore/Model/Notification/NotificationHeaderInfo.swift index 33fb1120..216b7ec6 100644 --- a/TwidereSDK/Sources/TwidereCore/Model/Notification/NotificationHeaderInfo.swift +++ b/TwidereSDK/Sources/TwidereCore/Model/Notification/NotificationHeaderInfo.swift @@ -12,6 +12,7 @@ import CoreDataStack import TwidereAsset import TwidereLocalization import MetaTextKit +import MastodonMeta public struct NotificationHeaderInfo { @@ -100,6 +101,7 @@ extension NotificationHeaderInfo { // assertionFailure() return nil } - return Meta.convert(from: .mastodon(string: text, emojis: user.emojis.asDictionary)) + let content = MastodonContent(content: text, emojis: user.emojisTransient.asDictionary) + return Meta.convert(document: .mastodon(content: content)) } } diff --git a/TwidereSDK/Sources/TwidereCore/Model/Notification/NotificationObject.swift b/TwidereSDK/Sources/TwidereCore/Model/Notification/NotificationObject.swift index 7b22d7df..1ef29bab 100644 --- a/TwidereSDK/Sources/TwidereCore/Model/Notification/NotificationObject.swift +++ b/TwidereSDK/Sources/TwidereCore/Model/Notification/NotificationObject.swift @@ -9,15 +9,31 @@ import Foundation import CoreDataStack -public enum NotificationObject { +public enum NotificationObject: Hashable { + case twitter(object: TwitterStatus) case mastodon(object: MastodonNotification) } +extension NotificationObject { + public var asRecord: NotificationRecord { + switch self { + case .twitter(let object): + return .twitter(record: object.asRecrod) + case .mastodon(let object): + return .mastodon(record: object.asRecrod) + } + } +} + extension NotificationObject { public var status: StatusObject? { switch self { + case .twitter(let object): + let status = object + return .twitter(object: status) case .mastodon(let object): - return object.status.flatMap { .mastodon(object: $0) } + guard let status = object.status else { return nil } + return .mastodon(object: status) } // end swich } // end func } diff --git a/TwidereSDK/Sources/TwidereCore/Model/Notification/NotificationRecord.swift b/TwidereSDK/Sources/TwidereCore/Model/Notification/NotificationRecord.swift index 37565da1..e491933c 100644 --- a/TwidereSDK/Sources/TwidereCore/Model/Notification/NotificationRecord.swift +++ b/TwidereSDK/Sources/TwidereCore/Model/Notification/NotificationRecord.swift @@ -11,15 +11,19 @@ import CoreData import CoreDataStack public enum NotificationRecord: Hashable { + case twitter(record: ManagedObjectRecord) case mastodon(record: ManagedObjectRecord) } extension NotificationRecord { public func object(in managedObjectContext: NSManagedObjectContext) -> NotificationObject? { switch self { + case .twitter(let record): + guard let object = record.object(in: managedObjectContext) else { return nil } + return .twitter(object: object) case .mastodon(let record): - guard let notification = record.object(in: managedObjectContext) else { return nil } - return .mastodon(object: notification) + guard let object = record.object(in: managedObjectContext) else { return nil } + return .mastodon(object: object) } } } @@ -29,9 +33,12 @@ extension NotificationRecord { return await managedObjectContext.perform { guard let object = self.object(in: managedObjectContext) else { return nil } switch object { + case .twitter(let object): + let status = object + return .twitter(record: status.asRecrod) case .mastodon(let object): - guard let objectID = object.status?.objectID else { return nil } - return .mastodon(record: .init(objectID: objectID)) + guard let status = object.status else { return nil } + return .mastodon(record: status.asRecrod) } } } // end func diff --git a/TwidereSDK/Sources/TwidereCore/Model/Status/StatusObject.swift b/TwidereSDK/Sources/TwidereCore/Model/Status/StatusObject.swift index b6786aaf..1cf6af9e 100644 --- a/TwidereSDK/Sources/TwidereCore/Model/Status/StatusObject.swift +++ b/TwidereSDK/Sources/TwidereCore/Model/Status/StatusObject.swift @@ -43,7 +43,7 @@ extension StatusObject { return status.displayText case .mastodon(let status): do { - let content = MastodonContent(content: status.content, emojis: status.emojis.asDictionary) + let content = MastodonContent(content: status.content, emojis: status.emojisTransient.asDictionary) let metaContent = try MastodonMetaContent.convert(document: content) return metaContent.original } catch { @@ -65,9 +65,20 @@ extension StatusObject { switch self { case .twitter(let status): let status = status.repost ?? status - return status.attachments.map { .twitter($0) } + return status.attachmentsTransient.map { .twitter($0) } case .mastodon(let status): - return status.attachments.map { .mastodon($0) } + return status.attachmentsTransient.map { .mastodon($0) } + } + } +} + +extension StatusObject { + public var histories: Set { + switch self { + case .twitter(let status): + return status.histories + case .mastodon(let status): + return status.histories } } } diff --git a/TwidereSDK/Sources/TwidereCore/Model/Status/StatusRecord.swift b/TwidereSDK/Sources/TwidereCore/Model/Status/StatusRecord.swift index d22b37d7..8ccfd9ec 100644 --- a/TwidereSDK/Sources/TwidereCore/Model/Status/StatusRecord.swift +++ b/TwidereSDK/Sources/TwidereCore/Model/Status/StatusRecord.swift @@ -9,7 +9,6 @@ import Foundation import CoreData import CoreDataStack -import TwidereCommon public enum StatusRecord: Hashable { case twitter(record: ManagedObjectRecord) diff --git a/TwidereSDK/Sources/TwidereCore/Model/Status/StatusVisibility.swift b/TwidereSDK/Sources/TwidereCore/Model/Status/StatusVisibility.swift deleted file mode 100644 index a09fd54b..00000000 --- a/TwidereSDK/Sources/TwidereCore/Model/Status/StatusVisibility.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// StatusVisibility.swift -// -// -// Created by MainasuK on 2021-12-6. -// - -import UIKit -import MastodonSDK -import TwidereAsset -import TwidereLocalization - -public enum StatusVisibility { - case mastodon(Mastodon.Entity.Status.Visibility) -} - -extension StatusVisibility { - public var inlineImage: UIImage? { - switch self { - case .mastodon(let visibility): - switch visibility { - case .public: - return Asset.ObjectTools.globeMiniInline.image.withRenderingMode(.alwaysTemplate) - case .unlisted: - return Asset.ObjectTools.lockOpenMiniInline.image.withRenderingMode(.alwaysTemplate) - case .private: - return Asset.ObjectTools.lockMiniInline.image.withRenderingMode(.alwaysTemplate) - case .direct: - return Asset.Communication.mailMiniInline.image.withRenderingMode(.alwaysTemplate) - case ._other: - return nil - } - } - } -} - -extension StatusVisibility { - public var accessibilityLabel: String? { - switch self { - case .mastodon(let visibility): - switch visibility { - case .public: - return L10n.Scene.Compose.Visibility.public - case .unlisted: - return L10n.Scene.Compose.Visibility.unlisted - case .private: - return L10n.Scene.Compose.Visibility.private - case .direct: - return L10n.Scene.Compose.Visibility.direct - case ._other: - return nil - } - } - } -} diff --git a/TwidereSDK/Sources/TwidereCore/Model/User/UserObject.swift b/TwidereSDK/Sources/TwidereCore/Model/User/UserObject.swift index c72b48a1..2a1e0705 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: @@ -86,4 +99,24 @@ extension UserObject { return object.avatar.flatMap { URL(string: $0) } } } + + public var protected: Bool { + switch self { + case .twitter(let object): + return object.protected + case .mastodon(let object): + return object.locked + } + } +} + +extension UserObject { + public var histories: Set { + switch self { + case .twitter(let user): + return user.histories + case .mastodon(let user): + return user.histories + } + } } diff --git a/TwidereSDK/Sources/TwidereCore/Persistence/Mastodon/Extension/MastodonStatus+Property.swift b/TwidereSDK/Sources/TwidereCore/Persistence/Mastodon/Extension/MastodonStatus+Property.swift index 0f651d20..764555f7 100644 --- a/TwidereSDK/Sources/TwidereCore/Persistence/Mastodon/Extension/MastodonStatus+Property.swift +++ b/TwidereSDK/Sources/TwidereCore/Persistence/Mastodon/Extension/MastodonStatus+Property.swift @@ -37,9 +37,9 @@ extension MastodonStatus.Property { replyToUserID: entity.inReplyToAccountID, createdAt: entity.createdAt, updatedAt: networkDate, - attachments: entity.mastodonAttachments, - emojis: entity.mastodonEmojis, - mentions: entity.mastodonMentions + attachmentsTransient: entity.mastodonAttachments, + emojisTransient: entity.mastodonEmojis, + mentionsTransient: entity.mastodonMentions ) } } diff --git a/TwidereSDK/Sources/TwidereCore/Persistence/Mastodon/Extension/MastodonUser+Property.swift b/TwidereSDK/Sources/TwidereCore/Persistence/Mastodon/Extension/MastodonUser+Property.swift index 199e3bc5..55df28cf 100644 --- a/TwidereSDK/Sources/TwidereCore/Persistence/Mastodon/Extension/MastodonUser+Property.swift +++ b/TwidereSDK/Sources/TwidereCore/Persistence/Mastodon/Extension/MastodonUser+Property.swift @@ -32,8 +32,8 @@ extension MastodonUser.Property { suspended: entity.suspended ?? false, createdAt: entity.createdAt, updatedAt: networkDate, - emojis: entity.mastodonEmojis, - fields: entity.mastodonFields + emojisTransient: entity.mastodonEmojis, + fieldsTransient: entity.mastodonFields ) } } diff --git a/TwidereSDK/Sources/TwidereCore/Persistence/Twitter/Extension/TwitterEntity.swift b/TwidereSDK/Sources/TwidereCore/Persistence/Twitter/Extension/TwitterEntity.swift index e217490f..a622a664 100644 --- a/TwidereSDK/Sources/TwidereCore/Persistence/Twitter/Extension/TwitterEntity.swift +++ b/TwidereSDK/Sources/TwidereCore/Persistence/Twitter/Extension/TwitterEntity.swift @@ -102,7 +102,7 @@ extension TwitterEntity.URLEntity { ) } - public init(entity: Twitter.Entity.V2.Entities.URL) { + public init(entity: Twitter.Entity.V2.Entities.URLNode) { self.init( start: entity.start, end: entity.end, diff --git a/TwidereSDK/Sources/TwidereCore/Persistence/Twitter/Extension/TwitterStatus+Property.swift b/TwidereSDK/Sources/TwidereCore/Persistence/Twitter/Extension/TwitterStatus+Property.swift index 36af73a9..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 ) @@ -196,7 +198,10 @@ extension Twitter.Entity.V2.Media { guard let kind = attachmentKind else { return nil } guard let width = width, let height = height - else { return nil } + else { + assertionFailure() + return nil + } return TwitterAttachment( kind: kind, size: CGSize(width: width, height: height), diff --git a/TwidereSDK/Sources/TwidereCore/Persistence/Twitter/Persistence+Twitter.swift b/TwidereSDK/Sources/TwidereCore/Persistence/Twitter/Persistence+Twitter.swift index 7339210b..46439a19 100644 --- a/TwidereSDK/Sources/TwidereCore/Persistence/Twitter/Persistence+Twitter.swift +++ b/TwidereSDK/Sources/TwidereCore/Persistence/Twitter/Persistence+Twitter.swift @@ -1,5 +1,5 @@ // -// .swift +// Persistence+Twitter.swift // Persistence+TwiPersistence+Twittertter // // Created by Cirno MainasuK on 2021-8-31. @@ -62,7 +62,10 @@ extension Persistence.Twitter { for status in context.dictionary.tweetDict.values { guard let authorID = status.authorID, let author = context.dictionary.userDict[authorID] - else { continue } + else { + assertionFailure() + continue + } var repost: Persistence.TwitterStatus.PersistContextV2.Entity? var replyTo: Persistence.TwitterStatus.PersistContextV2.Entity? @@ -71,7 +74,10 @@ extension Persistence.Twitter { for referencedTweet in status.referencedTweets ?? [] { guard let type = referencedTweet.type, let statusID = referencedTweet.id - else { continue } + else { + assertionFailure() + continue + } guard let status = context.dictionary.tweetDict[statusID], let authorID = status.authorID, let author = context.dictionary.userDict[authorID] @@ -87,19 +93,20 @@ extension Persistence.Twitter { } // end switch } // end for + let contextV2 = Persistence.TwitterStatus.PersistContextV2( + entity: .init(status: status, author: author), + repost: repost, + quote: quote, + replyTo: replyTo, + dictionary: context.dictionary, + me: context.me, + statusCache: statusCache, + userCache: userCache, + networkDate: context.networkDate + ) let result = Persistence.TwitterStatus.createOrMerge( in: managedObjectContext, - context: Persistence.TwitterStatus.PersistContextV2( - entity: .init(status: status, author: author), - repost: repost, - quote: quote, - replyTo: replyTo, - dictionary: context.dictionary, - me: context.me, - statusCache: statusCache, - userCache: userCache, - networkDate: context.networkDate - ) + context: contextV2 ) // end .createOrMerge(…) #if DEBUG diff --git a/TwidereSDK/Sources/TwidereCore/Persistence/Twitter/Persistence+TwitterStatus+V2.swift b/TwidereSDK/Sources/TwidereCore/Persistence/Twitter/Persistence+TwitterStatus+V2.swift index fab0c575..50d9e1ea 100644 --- a/TwidereSDK/Sources/TwidereCore/Persistence/Twitter/Persistence+TwitterStatus+V2.swift +++ b/TwidereSDK/Sources/TwidereCore/Persistence/Twitter/Persistence+TwitterStatus+V2.swift @@ -141,6 +141,9 @@ extension Persistence.TwitterStatus { if let old = fetch(in: managedObjectContext, context: context) { merge(twitterStatus: old, context: context) + if let repost = repost, old.repost == nil { + old.update(repost: repost) + } return .init(status: old, isNewInsertion: false, isNewInsertionAuthor: false) } else { let poll: TwitterPoll? = { @@ -274,14 +277,14 @@ extension Persistence.TwitterStatus { context: PersistContextV2 ) { // entities - status.update(entities: TwitterEntity(entity: context.entity.status.entities)) + status.update(entitiesTransient: TwitterEntity(entity: context.entity.status.entities)) // replySettings (v2 only) let _replySettings = context.entity.status.replySettings.flatMap { TwitterReplySettings(value: $0.rawValue) } if let replySettings = _replySettings { - status.update(replySettings: replySettings) + status.update(replySettingsTransient: replySettings) } // conversationID (v2 only) @@ -301,20 +304,20 @@ extension Persistence.TwitterStatus { // do not update video & GIFV attachments except isEmpty let isVideo = media.contains(where: { $0.type == TwitterAttachment.Kind.animatedGIF.rawValue || $0.type == TwitterAttachment.Kind.video.rawValue }) if isVideo { - let isEmpty = status.attachments.isEmpty + let isEmpty = status.attachmentsTransient.isEmpty if !isEmpty { return } } let attachments = media.compactMap { $0.twitterAttachment } - status.update(attachments: attachments) + status.update(attachmentsTransient: attachments) } // place (not stable: geo may erased) context.dictionary.place(for: context.entity.status) .flatMap { place in - status.update(location: place.twitterLocation) + status.update(locationTransient: place.twitterLocation) } } diff --git a/TwidereSDK/Sources/TwidereCore/Persistence/Twitter/Persistence+TwitterStatus.swift b/TwidereSDK/Sources/TwidereCore/Persistence/Twitter/Persistence+TwitterStatus.swift index f5c40b13..440f6eec 100644 --- a/TwidereSDK/Sources/TwidereCore/Persistence/Twitter/Persistence+TwitterStatus.swift +++ b/TwidereSDK/Sources/TwidereCore/Persistence/Twitter/Persistence+TwitterStatus.swift @@ -98,6 +98,9 @@ extension Persistence.TwitterStatus { if let oldStatus = fetch(in: managedObjectContext, context: context) { merge(twitterStatus: oldStatus, context: context) + if let repost = repost, oldStatus.repost == nil { + oldStatus.update(repost: repost) + } return PersistResult( status: oldStatus, isNewInsertion: false, @@ -203,15 +206,15 @@ extension Persistence.TwitterStatus { context: PersistContext ) { // prefer use V2 entities. only update entities when not exist - if status.entities == nil { - status.update(entities: TwitterEntity( + if status.entitiesTransient == nil { + status.update(entitiesTransient: TwitterEntity( entity: context.entity.entities, extendedEntity: context.entity.extendedEntities )) } - context.entity.twitterAttachments.flatMap { status.update(attachments: $0) } - context.entity.twitterLocation.flatMap { status.update(location:$0) } + context.entity.twitterAttachments.flatMap { status.update(attachmentsTransient: $0) } + context.entity.twitterLocation.flatMap { status.update(locationTransient: $0) } // update relationship if let me = context.me { diff --git a/TwidereSDK/Sources/TwidereCore/Persistence/Twitter/Persistence+TwitterUser+V2.swift b/TwidereSDK/Sources/TwidereCore/Persistence/Twitter/Persistence+TwitterUser+V2.swift index 696253be..01ec5dbd 100644 --- a/TwidereSDK/Sources/TwidereCore/Persistence/Twitter/Persistence+TwitterUser+V2.swift +++ b/TwidereSDK/Sources/TwidereCore/Persistence/Twitter/Persistence+TwitterUser+V2.swift @@ -6,11 +6,11 @@ // Copyright © 2021 Twidere. All rights reserved. // +import os.log import CoreData import CoreDataStack import Foundation import TwitterSDK -import os.log extension Persistence.TwitterUser { @@ -102,8 +102,28 @@ extension Persistence.TwitterUser { twitterUser user: TwitterUser, context: PersistContextV2 ) { - user.update(bioEntities: TwitterEntity(entity: context.entity.entities?.description)) - user.update(urlEntities: TwitterEntity(entity: context.entity.entities?.url)) + user.update(bioEntitiesTransient: TwitterEntity(entity: context.entity.entities?.description)) + user.update(urlEntitiesTransient: TwitterEntity(entity: context.entity.entities?.url)) + + if let publicMetrics = context.entity.publicMetrics { + if let tweetCount = publicMetrics.tweetCount { + user.update(statusesCount: Int64(tweetCount)) + } + if let followingCount = publicMetrics.followingCount { + user.update(followingCount: Int64(followingCount)) + } + if let followersCount = publicMetrics.followersCount { + user.update(followersCount: Int64(followersCount)) + } + if let listedCount = publicMetrics.listedCount { + user.update(listedCount: Int64(listedCount)) + } + } + + // convertible properties + if let profileBannerURL = context.entity.profileBannerURL { + user.update(profileBannerURL: profileBannerURL) + } // V2 entity not contains relationship flags } diff --git a/TwidereSDK/Sources/TwidereCore/Persistence/Twitter/Persistence+TwitterUser.swift b/TwidereSDK/Sources/TwidereCore/Persistence/Twitter/Persistence+TwitterUser.swift index df83510a..d47b3db4 100644 --- a/TwidereSDK/Sources/TwidereCore/Persistence/Twitter/Persistence+TwitterUser.swift +++ b/TwidereSDK/Sources/TwidereCore/Persistence/Twitter/Persistence+TwitterUser.swift @@ -115,8 +115,13 @@ extension Persistence.TwitterUser { context: PersistContext ) { user.update(profileBannerURL: context.entity.profileBannerURL) - user.update(bioEntities: TwitterEntity(entity: context.entity.entities?.description)) - user.update(urlEntities: TwitterEntity(entity: context.entity.entities?.url)) + user.update(bioEntitiesTransient: TwitterEntity(entity: context.entity.entities?.description)) + user.update(urlEntitiesTransient: TwitterEntity(entity: context.entity.entities?.url)) + + context.entity.statusesCount.flatMap { user.update(statusesCount: Int64($0)) } + context.entity.friendsCount.flatMap { user.update(followingCount: Int64($0)) } + context.entity.followersCount.flatMap { user.update(followersCount: Int64($0)) } + context.entity.listedCount.flatMap { user.update(listedCount: Int64($0)) } // relationship if let me = context.me { diff --git a/TwidereSDK/Sources/TwidereCore/Protocol/TextStyleConfigurable.swift b/TwidereSDK/Sources/TwidereCore/Protocol/TextStyleConfigurable.swift index 3584f717..e250ac4d 100644 --- a/TwidereSDK/Sources/TwidereCore/Protocol/TextStyleConfigurable.swift +++ b/TwidereSDK/Sources/TwidereCore/Protocol/TextStyleConfigurable.swift @@ -9,6 +9,7 @@ import UIKit import Meta import MetaTextKit +import MetaLabel import MetaTextArea import TwidereAsset @@ -24,11 +25,13 @@ public enum TextStyle { case statusTimestamp case statusLocation case statusContent + case statusTranslateButton case statusMetrics case userAuthorName case pollOptionTitle case pollOptionPercentage case pollVoteDescription + case pollVoteButton case userAuthorUsername case userDescription case profileAuthorName @@ -71,10 +74,12 @@ 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 case .pollVoteDescription: return 1 + case .pollVoteButton: return 1 case .userAuthorName: return 1 case .userAuthorUsername: return 1 case .userDescription: return 1 @@ -113,14 +118,18 @@ 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: - return .systemFont(ofSize: 15, weight: .regular) + return UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 14)) case .pollOptionPercentage: - return .systemFont(ofSize: 12, weight: .regular) + return UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 14)) case .pollVoteDescription: - return .systemFont(ofSize: 14, weight: .regular) + return UIFontMetrics(forTextStyle: .callout).scaledFont(for: .systemFont(ofSize: 14)) + case .pollVoteButton: + return UIFontMetrics(forTextStyle: .callout).scaledFont(for: .systemFont(ofSize: 14, weight: .medium)) case .userAuthorName: return .preferredFont(forTextStyle: .headline) case .userAuthorUsername: @@ -176,6 +185,8 @@ extension TextStyle { return .secondaryLabel case .statusContent: return .label.withAlphaComponent(0.8) + case .statusTranslateButton: + return .tintColor case .statusMetrics: return .secondaryLabel case .userAuthorName: @@ -186,6 +197,8 @@ extension TextStyle { return .secondaryLabel case .pollVoteDescription: return .secondaryLabel + case .pollVoteButton: + return .tintColor case .userAuthorUsername: return .secondaryLabel case .userDescription: @@ -201,7 +214,8 @@ extension TextStyle { case .profileFieldValue: return .label case .mediaDescriptionAuthorName: - return .label + // force white due to media view controller override to dark mode + return .white case .hashtagTitle: return .label case .hashtagDescription: @@ -235,80 +249,13 @@ extension MetaLabel: TextStyleConfigurable { } public func setupLayout(style: TextStyle) { - lineBreakMode = .byTruncatingTail - textContainer.lineBreakMode = .byTruncatingTail - textContainer.lineFragmentPadding = 0 - - numberOfLines = style.numberOfLines - - switch style { - case .statusHeader: - break - case .statusAuthorName: - break - case .statusAuthorUsername: - break - case .statusTimestamp: - break - case .statusLocation: - break - case .statusContent: - break - case .statusMetrics: - break - case .pollOptionTitle: - break - case .pollOptionPercentage: - break - case .pollVoteDescription: - break - case .userAuthorName: - break - case .userAuthorUsername: - break - case .userDescription: - break - case .profileAuthorName, .profileAuthorUsername: - textAlignment = .center - paragraphStyle.alignment = .center - case .profileAuthorBio: - break - case .profileFieldKey: - break - case .profileFieldValue: - break - case .mediaDescriptionAuthorName: - break - case .hashtagTitle: - break - case .hashtagDescription: - break - case .listPrimaryText: - break - case .searchHistoryTitle: - break - case .searchTrendTitle: - break - case .searchTrendSubtitle: - break - case .searchTrendCount: - break - case .sidebarAuthorName: - break - case .sidebarAuthorUsername: - break - case .custom: - break - } + // do nothing due to cannot tweak TextKit 2 } public func setupAttributes(style: TextStyle) { let font = style.font let textColor = style.textColor - self.font = font - self.textColor = textColor - textAttributes = [ .font: font, .foregroundColor: textColor diff --git a/TwidereSDK/Sources/TwidereCore/Service/APIService/APIService+Authentication.swift b/TwidereSDK/Sources/TwidereCore/Service/APIService/APIService+Authentication.swift index e6f8cafd..559f5727 100644 --- a/TwidereSDK/Sources/TwidereCore/Service/APIService/APIService+Authentication.swift +++ b/TwidereSDK/Sources/TwidereCore/Service/APIService/APIService+Authentication.swift @@ -9,7 +9,6 @@ import Foundation import Combine import TwitterSDK import MastodonSDK -import TwidereCommon extension APIService { diff --git a/TwidereSDK/Sources/TwidereCore/Service/APIService/APIService+FriendshipList.swift b/TwidereSDK/Sources/TwidereCore/Service/APIService/APIService+FriendshipList.swift index 1120143e..1ddf1b75 100644 --- a/TwidereSDK/Sources/TwidereCore/Service/APIService/APIService+FriendshipList.swift +++ b/TwidereSDK/Sources/TwidereCore/Service/APIService/APIService+FriendshipList.swift @@ -13,6 +13,7 @@ import CoreDataStack import TwitterSDK import MastodonSDK +// Twitter V2 extension APIService { public func twitterUserFollowingList( diff --git a/TwidereSDK/Sources/TwidereCore/Service/APIService/APIService+Media.swift b/TwidereSDK/Sources/TwidereCore/Service/APIService/APIService+Media.swift index caed87c7..ca4ad683 100644 --- a/TwidereSDK/Sources/TwidereCore/Service/APIService/APIService+Media.swift +++ b/TwidereSDK/Sources/TwidereCore/Service/APIService/APIService+Media.swift @@ -60,7 +60,7 @@ extension APIService { public func twitterMediaStatus( mediaID: String, twitterAuthenticationContext: TwitterAuthenticationContext - ) async throws -> Twitter.Response.Content { + ) async throws -> Twitter.Response.Content { let authorization = twitterAuthenticationContext.authorization let query = Twitter.API.Media.StatusQuery(mediaID: mediaID) return try await Twitter.API.Media.status( diff --git a/TwidereSDK/Sources/TwidereCore/Service/APIService/APIService+Notification.swift b/TwidereSDK/Sources/TwidereCore/Service/APIService/APIService+Notification.swift index 12f01db0..01fcce8b 100644 --- a/TwidereSDK/Sources/TwidereCore/Service/APIService/APIService+Notification.swift +++ b/TwidereSDK/Sources/TwidereCore/Service/APIService/APIService+Notification.swift @@ -8,7 +8,6 @@ import os.log import Foundation import MastodonSDK -import TwidereCommon extension APIService { diff --git a/TwidereSDK/Sources/TwidereCore/Service/APIService/APIService+Status+Conversation.swift b/TwidereSDK/Sources/TwidereCore/Service/APIService/APIService+Status+Conversation.swift new file mode 100644 index 00000000..272df39a --- /dev/null +++ b/TwidereSDK/Sources/TwidereCore/Service/APIService/APIService+Status+Conversation.swift @@ -0,0 +1,63 @@ +// +// APIService+Status+Conversation.swift +// +// +// Created by MainasuK on 2023/3/28. +// + +import os.log +import Foundation +import CoreDataStack +import TwitterSDK +import func QuartzCore.CACurrentMediaTime + +extension APIService { + public func twitterStatusConversation( + conversationRootStatusID: Twitter.Entity.V2.Tweet.ID, + query: Twitter.API.V2.Status.Timeline.ConvsersationQuery, + authenticationContext: TwitterAuthenticationContext + ) async throws -> Twitter.Response.Content { + let response = try await Twitter.API.V2.Status.Timeline.conversation( + session: URLSession(configuration: .ephemeral), + statusID: conversationRootStatusID, + query: query, + authorization: authenticationContext.authorization + ) + + #if DEBUG + // log time cost + let start = CACurrentMediaTime() + defer { + // log rate limit + response.logRateLimit() + + let end = CACurrentMediaTime() + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: persist cost %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - start) + } + #endif + + let managedObjectContext = backgroundManagedObjectContext + try await managedObjectContext.performChanges { + let content = response.value + let dictionary = Twitter.Response.V2.DictContent( + tweets: content.includes?.tweets ?? [], + users: content.includes?.users ?? [], + media: content.includes?.media ?? [], + places: content.includes?.places ?? [], + polls: content.includes?.polls ?? [] + ) + let me = authenticationContext.authenticationRecord.object(in: managedObjectContext)?.user + + _ = Persistence.Twitter.persist( + in: managedObjectContext, + context: Persistence.Twitter.PersistContextV2( + dictionary: dictionary, + me: me, + networkDate: response.networkDate + ) + ) + } // end .performChanges { … } + + return response + } +} diff --git a/TwidereSDK/Sources/TwidereCore/Service/APIService/APIService+Status+List.swift b/TwidereSDK/Sources/TwidereCore/Service/APIService/APIService+Status+List.swift index 235b3ac5..9057ee1c 100644 --- a/TwidereSDK/Sources/TwidereCore/Service/APIService/APIService+Status+List.swift +++ b/TwidereSDK/Sources/TwidereCore/Service/APIService/APIService+Status+List.swift @@ -18,11 +18,12 @@ extension APIService { list: ManagedObjectRecord, query: Twitter.API.V2.Status.List.StatusesQuery, authenticationContext: TwitterAuthenticationContext - ) async throws -> Twitter.Response.Content { + ) async throws -> Twitter.Response.Content { let managedObjectContext = backgroundManagedObjectContext let _listID: TwitterList.ID? = await managedObjectContext.perform { guard let list = list.object(in: managedObjectContext) else { return nil } + return list.id } guard let listID = _listID else { @@ -36,14 +37,41 @@ extension APIService { authorization: authenticationContext.authorization ) - if let statusIDs = response.value.data?.compactMap({ $0.id }), !statusIDs.isEmpty { - assert(statusIDs.count <= 100) - _ = try await twitterStatus( - statusIDs: statusIDs, - authenticationContext: authenticationContext - ) + #if DEBUG + // log time cost + let start = CACurrentMediaTime() + defer { + // log rate limit + response.logRateLimit() + + let end = CACurrentMediaTime() + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: persist cost %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - start) } + #endif + let content = response.value + let dictionary = Twitter.Response.V2.DictContent( + tweets: [content.data, content.includes?.tweets].compactMap { $0 }.flatMap { $0 }, + users: content.includes?.users ?? [], + media: content.includes?.media ?? [], + places: content.includes?.places ?? [], + polls: content.includes?.polls ?? [] + ) + + try await managedObjectContext.performChanges { + let me = authenticationContext.authenticationRecord.object(in: managedObjectContext)?.user + + let contextV2 = Persistence.Twitter.PersistContextV2( + dictionary: dictionary, + me: me, + networkDate: response.networkDate + ) + _ = Persistence.Twitter.persist( + in: managedObjectContext, + context: contextV2 + ) + } // end .performChanges { … } + return response } @@ -56,13 +84,7 @@ extension APIService { query: query, authorization: authenticationContext.authorization ) - - let statusIDs: [Twitter.Entity.V2.Tweet.ID] = response.value.map { $0.idStr } - let _lookupResponse = try? await twitterBatchLookupV2( - statusIDs: statusIDs, - authenticationContext: authenticationContext - ) - + #if DEBUG // log time cost let start = CACurrentMediaTime() @@ -93,11 +115,6 @@ extension APIService { ) statusArray.append(result.status) } // end for in - - // amend the v2 only properties - if let lookupResponse = _lookupResponse, let me = me { - lookupResponse.update(statuses: statusArray, me: me) - } } return response diff --git a/TwidereSDK/Sources/TwidereCore/Service/APIService/APIService+Lookup.swift b/TwidereSDK/Sources/TwidereCore/Service/APIService/APIService+Status+Lookup.swift similarity index 72% rename from TwidereSDK/Sources/TwidereCore/Service/APIService/APIService+Lookup.swift rename to TwidereSDK/Sources/TwidereCore/Service/APIService/APIService+Status+Lookup.swift index c2a74a93..15b928e9 100644 --- a/TwidereSDK/Sources/TwidereCore/Service/APIService/APIService+Lookup.swift +++ b/TwidereSDK/Sources/TwidereCore/Service/APIService/APIService+Status+Lookup.swift @@ -9,6 +9,7 @@ import os.log import Foundation import Combine import TwitterSDK +import MastodonSDK import CoreDataStack import CommonOSLog import func QuartzCore.CACurrentMediaTime @@ -98,3 +99,39 @@ extension APIService { return response } } + +extension APIService { + public func mastodonStatus( + statusID: Mastodon.Entity.Status.ID, + authenticationContext: MastodonAuthenticationContext + ) async throws -> Mastodon.Response.Content { + let domain = authenticationContext.domain + let authorization = authenticationContext.authorization + let managedObjectContext = backgroundManagedObjectContext + + let response = try await Mastodon.API.Status.lookup( + session: session, + domain: domain, + query: Mastodon.API.Status.LookupStatusQuery(id: statusID), + authorization: authorization + ) + + try await managedObjectContext.performChanges { + let entity = response.value + let me = authenticationContext.authenticationRecord.object(in: managedObjectContext)?.user + _ = Persistence.MastodonStatus.createOrMerge( + in: managedObjectContext, + context: .init( + domain: authenticationContext.domain, + entity: entity, + me: me, + statusCache: nil, + userCache: nil, + networkDate: response.networkDate + ) + ) + } + + return response + } +} diff --git a/TwidereSDK/Sources/TwidereCore/Service/APIService/APIService+Status+Publish.swift b/TwidereSDK/Sources/TwidereCore/Service/APIService/APIService+Status+Publish.swift index 06a6c6bd..f6be0443 100644 --- a/TwidereSDK/Sources/TwidereCore/Service/APIService/APIService+Status+Publish.swift +++ b/TwidereSDK/Sources/TwidereCore/Service/APIService/APIService+Status+Publish.swift @@ -95,12 +95,24 @@ extension APIService { query: Twitter.API.V2.Status.PublishQuery, twitterAuthenticationContext: TwitterAuthenticationContext ) async throws -> Twitter.Response.Content { + let authenticationContext = AuthenticationContext.twitter(authenticationContext: twitterAuthenticationContext) + + let now = Date() + if let rateLimit = authenticationContext.rateLimit(scope: .publish), now < rateLimit.reset, rateLimit.remaining <= 0 { + throw AppError.explicit(.requestThrottle) + } + let response = try await Twitter.API.V2.Status.publish( session: session, query: query, authorization: twitterAuthenticationContext.authorization ) + authenticationContext.updateRateLimit( + scope: .publish, + now: response.networkDate + ) + return response } @@ -112,6 +124,13 @@ extension APIService { excludeReplyUserIDs: [TwitterUser.ID]?, twitterAuthenticationContext: TwitterAuthenticationContext ) async throws -> Twitter.Response.Content { + let authenticationContext = AuthenticationContext.twitter(authenticationContext: twitterAuthenticationContext) + + let now = Date() + if let rateLimit = authenticationContext.rateLimit(scope: .publish), now < rateLimit.reset, rateLimit.remaining <= 0 { + throw AppError.explicit(.requestThrottle) + } + let authorization = twitterAuthenticationContext.authorization let managedObjectContext = backgroundManagedObjectContext @@ -140,6 +159,11 @@ extension APIService { authorization: authorization ) + authenticationContext.updateRateLimit( + scope: .publish, + now: response.networkDate + ) + return response } diff --git a/TwidereSDK/Sources/TwidereCore/Service/APIService/APIService+Status+Search.swift b/TwidereSDK/Sources/TwidereCore/Service/APIService/APIService+Status+Search.swift index 07fcfce7..7ce1dcfe 100644 --- a/TwidereSDK/Sources/TwidereCore/Service/APIService/APIService+Status+Search.swift +++ b/TwidereSDK/Sources/TwidereCore/Service/APIService/APIService+Status+Search.swift @@ -54,13 +54,7 @@ extension APIService { query: query, authorization: authenticationContext.authorization ) - - let statusIDs: [Twitter.Entity.V2.Tweet.ID] = (response.value.statuses ?? []).map { $0.idStr } - let _lookupResponse = try? await twitterBatchLookupV2( - statusIDs: statusIDs, - authenticationContext: authenticationContext - ) - + #if DEBUG // log time cost let start = CACurrentMediaTime() @@ -91,11 +85,6 @@ extension APIService { ) statusArray.append(result.status) } // end for in - - // amend the v2 only properties - if let lookupResponse = _lookupResponse, let me = me { - lookupResponse.update(statuses: statusArray, me: me) - } } return response @@ -111,7 +100,7 @@ extension APIService { searchText: String, nextToken: String?, authenticationContext: TwitterAuthenticationContext - ) async throws -> Twitter.Response.Content { + ) async throws -> Twitter.Response.Content { let query = Twitter.API.V2.Search.RecentTweetQuery( query: searchText, maxResults: APIService.defaultSearchCount, @@ -133,7 +122,7 @@ extension APIService { startTime: Date?, nextToken: String?, authenticationContext: TwitterAuthenticationContext - ) async throws -> Twitter.Response.Content { + ) async throws -> Twitter.Response.Content { let query = Twitter.API.V2.Search.RecentTweetQuery( query: "conversation_id:\(conversationID) (to:\(authorID) OR from:\(authorID))", maxResults: APIService.conversationSearchCount, @@ -150,7 +139,7 @@ extension APIService { public func searchTwitterStatus( query: Twitter.API.V2.Search.RecentTweetQuery, authenticationContext: TwitterAuthenticationContext - ) async throws -> Twitter.Response.Content { + ) async throws -> Twitter.Response.Content { let response = try await Twitter.API.V2.Search.recentTweet( session: session, query: query, @@ -191,35 +180,7 @@ extension APIService { ) ) } // end .performChanges { … } - - // query and update entity video/GIF attribute from V1 API - do { - let statusIDs: [Twitter.Entity.Tweet.ID] = { - var statusIDs: Set = Set() - for status in response.value.data ?? [] { - guard let mediaKeys = status.attachments?.mediaKeys else { continue } - for mediaKey in mediaKeys { - guard let media = dictionary.mediaDict[mediaKey], - media.attachmentKind == .video || media.attachmentKind == .animatedGIF - else { continue } - - statusIDs.insert(status.id) - } - } - return Array(statusIDs) - }() - if !statusIDs.isEmpty { - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch \(statusIDs.count) missing assetURL from V1 API…") - _ = try await twitterStatusV1( - statusIDs: statusIDs, - authenticationContext: authenticationContext - ) - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch missing assetURL from V1 API success") - } - } catch { - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch missing assetURL from V1 API fail: \(error.localizedDescription)") - } - + return response } diff --git a/TwidereSDK/Sources/TwidereCore/Service/APIService/Timeline/APIService+Timeline+Home.swift b/TwidereSDK/Sources/TwidereCore/Service/APIService/Timeline/APIService+Timeline+Home.swift index 07be62bb..4cbbcaff 100644 --- a/TwidereSDK/Sources/TwidereCore/Service/APIService/Timeline/APIService+Timeline+Home.swift +++ b/TwidereSDK/Sources/TwidereCore/Service/APIService/Timeline/APIService+Timeline+Home.swift @@ -26,10 +26,14 @@ extension APIService { case persist([ManagedObjectRecord]) } + @available(*, deprecated, message: "") public func twitterHomeTimeline( query: Twitter.API.V2.User.Timeline.HomeQuery, authenticationContext: TwitterAuthenticationContext ) async throws -> [Twitter.Response.Content] { + assertionFailure() + throw Twitter.API.Error.InternalError(message: "API Deprecated") + #if DEBUG // log time cost let start = CACurrentMediaTime() @@ -118,7 +122,8 @@ extension APIService { sinceID: sinceID, untilID: nil, paginationToken: nextToken, - maxResults: query.maxResults + maxResults: query.maxResults, + onlyMedia: query.onlyMedia ), authorization: authenticationContext.authorization ) @@ -169,7 +174,7 @@ extension APIService { let statusArray = statusRecords.compactMap { $0.object(in: managedObjectContext) } assert(statusArray.count == statusRecords.count) - + // amend the v2 missing properties if let me = me { var batchLookupResponse = TwitterBatchLookupResponse() @@ -181,15 +186,15 @@ extension APIService { batchLookupResponse.update(statuses: statusArray, me: me) } - // locate anchor status - let anchorStatus: TwitterStatus? = { - guard let untilID = query.untilID else { return nil } - let request = TwitterStatus.sortedFetchRequest - request.predicate = TwitterStatus.predicate(id: untilID) - request.fetchLimit = 1 - return try? managedObjectContext.fetch(request).first - }() - + // locate anchor status + let anchorStatus: TwitterStatus? = { + guard let untilID = query.untilID else { return nil } + let request = TwitterStatus.sortedFetchRequest + request.predicate = TwitterStatus.predicate(id: untilID) + request.fetchLimit = 1 + return try? managedObjectContext.fetch(request).first + }() + // update hasMore flag for anchor status let acct = Feed.Acct.twitter(userID: authenticationContext.userID) if let anchorStatus = anchorStatus, diff --git a/TwidereSDK/Sources/TwidereCore/Service/APIService/Timeline/APIService+Timeline+Like.swift b/TwidereSDK/Sources/TwidereCore/Service/APIService/Timeline/APIService+Timeline+Like.swift index cd002269..0119dfd0 100644 --- a/TwidereSDK/Sources/TwidereCore/Service/APIService/Timeline/APIService+Timeline+Like.swift +++ b/TwidereSDK/Sources/TwidereCore/Service/APIService/Timeline/APIService+Timeline+Like.swift @@ -28,22 +28,7 @@ extension APIService { query: query, authorization: authenticationContext.authorization ) - - let statusIDs: [Twitter.Entity.Tweet.ID] = { - var ids: [Twitter.Entity.Tweet.ID] = [] - if let statuses = response.value.data { - ids.append(contentsOf: statuses.map { $0.id }) - } - if let statuses = response.value.includes?.tweets { - ids.append(contentsOf: statuses.map { $0.id }) - } - return Array(Set(ids)) - }() - let _lookupResponse = try? await twitterBatchLookup( - statusIDs: statusIDs, - authenticationContext: authenticationContext - ) - + #if DEBUG // log time cost let start = CACurrentMediaTime() @@ -70,7 +55,7 @@ extension APIService { let me = authenticationContext.authenticationRecord.object(in: managedObjectContext)?.user // persist [TwitterStatus] - let statusArray = Persistence.Twitter.persist( + let results = Persistence.Twitter.persist( in: managedObjectContext, context: Persistence.Twitter.PersistContextV2( dictionary: dictionary, @@ -78,12 +63,11 @@ extension APIService { networkDate: response.networkDate ) ) - - // amend the v2 missing properties - if let lookupResponse = _lookupResponse, let me = me { - lookupResponse.update(statuses: statusArray, me: me) + if let me = me, userID == me.id { + for result in results { + result.update(isLike: true, by: me) + } } - } // end try await managedObjectContext.performChanges return response @@ -98,13 +82,7 @@ extension APIService { query: query, authorization: authenticationContext.authorization ) - - let statusIDs: [Twitter.Entity.V2.Tweet.ID] = response.value.map { $0.idStr } - let _lookupResponse = try? await twitterBatchLookupV2( - statusIDs: statusIDs, - authenticationContext: authenticationContext - ) - + #if DEBUG // log time cost let start = CACurrentMediaTime() @@ -137,11 +115,6 @@ extension APIService { ) statusArray.append(result.status) } - - // amend the v2 only properties - if let lookupResponse = _lookupResponse, let me = me { - lookupResponse.update(statuses: statusArray, me: me) - } } return response diff --git a/TwidereSDK/Sources/TwidereCore/Service/APIService/Timeline/APIService+Timeline+User.swift b/TwidereSDK/Sources/TwidereCore/Service/APIService/Timeline/APIService+Timeline+User.swift index 6a6e13ff..020e2189 100644 --- a/TwidereSDK/Sources/TwidereCore/Service/APIService/Timeline/APIService+Timeline+User.swift +++ b/TwidereSDK/Sources/TwidereCore/Service/APIService/Timeline/APIService+Timeline+User.swift @@ -28,21 +28,6 @@ extension APIService { authorization: authenticationContext.authorization ) - let statusIDs: [Twitter.Entity.Tweet.ID] = { - var ids: [Twitter.Entity.Tweet.ID] = [] - if let statuses = response.value.data { - ids.append(contentsOf: statuses.map { $0.id }) - } - if let statuses = response.value.includes?.tweets { - ids.append(contentsOf: statuses.map { $0.id }) - } - return Array(Set(ids)) - }() - let _lookupResponse = try? await twitterBatchLookup( - statusIDs: statusIDs, - authenticationContext: authenticationContext - ) - #if DEBUG // log time cost let start = CACurrentMediaTime() @@ -69,7 +54,7 @@ extension APIService { let me = authenticationContext.authenticationRecord.object(in: managedObjectContext)?.user // persist [TwitterStatus] - let statusArray = Persistence.Twitter.persist( + _ = Persistence.Twitter.persist( in: managedObjectContext, context: Persistence.Twitter.PersistContextV2( dictionary: dictionary, @@ -77,11 +62,6 @@ extension APIService { networkDate: response.networkDate ) ) - - // amend the v2 missing properties - if let lookupResponse = _lookupResponse, let me = me { - lookupResponse.update(statuses: statusArray, me: me) - } } // end try await managedObjectContext.performChanges return response @@ -96,13 +76,7 @@ extension APIService { query: query, authorization: authenticationContext.authorization ) - - let statusIDs: [Twitter.Entity.V2.Tweet.ID] = response.value.map { $0.idStr } - let _lookupResponse = try? await twitterBatchLookupV2( - statusIDs: statusIDs, - authenticationContext: authenticationContext - ) - + #if DEBUG // log time cost let start = CACurrentMediaTime() @@ -135,11 +109,6 @@ extension APIService { ) statusArray.append(result.status) } - - // amend the v2 only properties - if let lookupResponse = _lookupResponse, let me = me { - lookupResponse.update(statuses: statusArray, me: me) - } } return response diff --git a/TwidereSDK/Sources/TwidereCore/Service/APIService/Timeline/APIService+Timeline.swift b/TwidereSDK/Sources/TwidereCore/Service/APIService/Timeline/APIService+Timeline.swift index 81955e96..736a3909 100644 --- a/TwidereSDK/Sources/TwidereCore/Service/APIService/Timeline/APIService+Timeline.swift +++ b/TwidereSDK/Sources/TwidereCore/Service/APIService/Timeline/APIService+Timeline.swift @@ -129,15 +129,15 @@ extension APIService { // Fetch v1 API again to update v2 missing properies extension APIService { - + public struct TwitterBatchLookupResponse { let logger = Logger(subsystem: "APIService", category: "TwitterBatchLookupResponse") - + public var lookupDict: [Twitter.Entity.Tweet.ID: Twitter.Entity.Tweet] = [:] - + public func update(status: TwitterStatus, me: TwitterUser) { guard let lookupStatus = lookupDict[status.id] else { return } - + // like state lookupStatus.favorited.flatMap { status.update(isLike: $0, by: me) @@ -152,25 +152,25 @@ extension APIService { let isGIF = twitterAttachments.contains(where: { $0.kind == .animatedGIF }) if isGIF { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fix GIF missing") - status.update(attachments: twitterAttachments) + status.update(attachmentsTransient: twitterAttachments) return } // media missing bug - if status.attachments.isEmpty { + if status.attachmentsTransient.isEmpty { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fix media missing") - status.update(attachments: twitterAttachments) + status.update(attachmentsTransient: twitterAttachments) return } } } - + public func update(statuses: [TwitterStatus], me: TwitterUser) { for status in statuses { update(status: status, me: me) } } } - + public func twitterBatchLookupResponses( statusIDs: [Twitter.Entity.Tweet.ID], authenticationContext: TwitterAuthenticationContext @@ -178,7 +178,7 @@ extension APIService { let chunks = stride(from: 0, to: statusIDs.count, by: 100).map { statusIDs[$0.. Twitter.Response.Content<[Twitter.Entity.Tweet]>? in let query = Twitter.API.Lookup.LookupQuery(ids: Array(chunk)) let response = try? await Twitter.API.Lookup.tweets( @@ -188,10 +188,10 @@ extension APIService { ) return response } - + return _responses.compactMap { $0 } } - + public func twitterBatchLookupResponses( content: Twitter.API.V2.User.Timeline.HomeContent, authenticationContext: TwitterAuthenticationContext @@ -202,143 +202,143 @@ extension APIService { ids.append(contentsOf: content.includes?.tweets?.map { $0.id } ?? []) return ids }() - - let responses = await twitterBatchLookupResponses( - statusIDs: statusIDs, - authenticationContext: authenticationContext - ) - - return responses - } - - public func twitterBatchLookup( - statusIDs: [Twitter.Entity.Tweet.ID], - authenticationContext: TwitterAuthenticationContext - ) async throws -> TwitterBatchLookupResponse { + let responses = await twitterBatchLookupResponses( statusIDs: statusIDs, authenticationContext: authenticationContext ) - - var lookupDict: [Twitter.Entity.Tweet.ID: Twitter.Entity.Tweet] = [:] - for response in responses { - for status in response.value { - lookupDict[status.idStr] = status - } - } - - return .init(lookupDict: lookupDict) - } - -} -// Fetch v2 API again to update v2 only properies -extension APIService { - - public struct TwitterBatchLookupResponseV2 { - let logger = Logger(subsystem: "APIService", category: "TwitterBatchLookupResponseV2") - - let dictionary: Twitter.Response.V2.DictContent - - public func update(status: TwitterStatus, me: TwitterUser) { - guard let lookupStatus = dictionary.tweetDict[status.id] else { return } - guard let managedObjectContext = status.managedObjectContext else { return } - - let now = Date() - - // poll - if let poll = dictionary.poll(for: lookupStatus) { - let result = Persistence.TwitterPoll.createOrMerge( - in: managedObjectContext, - context: .init( - entity: poll, - me: me, - networkDate: now - ) - ) - status.attach(poll: result.poll) - } - - // reply settings - if let value = lookupStatus.replySettings { - let replySettings = TwitterReplySettings(value: value.rawValue) - status.update(replySettings: replySettings) - } - } - - public func update(statuses: [TwitterStatus], me: TwitterUser) { - for status in statuses { - update(status: status, me: me) - } - } - } - - public func twitterBatchLookupResponsesV2( - statusIDs: [Twitter.Entity.V2.Tweet.ID], - authenticationContext: TwitterAuthenticationContext - ) async -> [Twitter.Response.Content] { - let chunks = stride(from: 0, to: statusIDs.count, by: 100).map { - statusIDs[$0.. Twitter.Response.Content? in - let response = try? await Twitter.API.V2.Lookup.statuses( - session: self.session, - query: .init(statusIDs: Array(chunk)), - authorization: authenticationContext.authorization - ) - return response - } - - return _responses.compactMap { $0 } + return responses } - public func twitterBatchLookupV2( - statusIDs: [Twitter.Entity.V2.Tweet.ID], - authenticationContext: TwitterAuthenticationContext - ) async throws -> TwitterBatchLookupResponseV2 { - let responses = await twitterBatchLookupResponsesV2( - statusIDs: statusIDs, - authenticationContext: authenticationContext - ) - - var tweets: [Twitter.Entity.V2.Tweet] = [] - var users: [Twitter.Entity.V2.User] = [] - var media: [Twitter.Entity.V2.Media] = [] - var places: [Twitter.Entity.V2.Place] = [] - var polls: [Twitter.Entity.V2.Tweet.Poll] = [] - - for response in responses { - if let value = response.value.data { - tweets.append(contentsOf: value) - } - if let value = response.value.includes?.tweets { - tweets.append(contentsOf: value) - } - if let value = response.value.includes?.users { - users.append(contentsOf: value) - } - if let value = response.value.includes?.media { - media.append(contentsOf: value) - } - if let value = response.value.includes?.places { - places.append(contentsOf: value) - } - if let value = response.value.includes?.polls { - polls.append(contentsOf: value) - } - } +// public func twitterBatchLookup( +// statusIDs: [Twitter.Entity.Tweet.ID], +// authenticationContext: TwitterAuthenticationContext +// ) async throws -> TwitterBatchLookupResponse { +// let responses = await twitterBatchLookupResponses( +// statusIDs: statusIDs, +// authenticationContext: authenticationContext +// ) +// +// var lookupDict: [Twitter.Entity.Tweet.ID: Twitter.Entity.Tweet] = [:] +// for response in responses { +// for status in response.value { +// lookupDict[status.idStr] = status +// } +// } +// +// return .init(lookupDict: lookupDict) +// } - let dictionary = Twitter.Response.V2.DictContent( - tweets: tweets, - users: users, - media: media, - places: places, - polls: polls - ) - - return .init(dictionary: dictionary) - } - } +//// Fetch v2 API again to update v2 only properies +//extension APIService { +// +// public struct TwitterBatchLookupResponseV2 { +// let logger = Logger(subsystem: "APIService", category: "TwitterBatchLookupResponseV2") +// +// let dictionary: Twitter.Response.V2.DictContent +// +// public func update(status: TwitterStatus, me: TwitterUser) { +// guard let lookupStatus = dictionary.tweetDict[status.id] else { return } +// guard let managedObjectContext = status.managedObjectContext else { return } +// +// let now = Date() +// +// // poll +// if let poll = dictionary.poll(for: lookupStatus) { +// let result = Persistence.TwitterPoll.createOrMerge( +// in: managedObjectContext, +// context: .init( +// entity: poll, +// me: me, +// networkDate: now +// ) +// ) +// status.attach(poll: result.poll) +// } +// +// // reply settings +// if let value = lookupStatus.replySettings { +// let replySettings = TwitterReplySettings(value: value.rawValue) +// status.update(replySettings: replySettings) +// } +// } +// +// public func update(statuses: [TwitterStatus], me: TwitterUser) { +// for status in statuses { +// update(status: status, me: me) +// } +// } +// } +// +// public func twitterBatchLookupResponsesV2( +// statusIDs: [Twitter.Entity.V2.Tweet.ID], +// authenticationContext: TwitterAuthenticationContext +// ) async -> [Twitter.Response.Content] { +// let chunks = stride(from: 0, to: statusIDs.count, by: 100).map { +// statusIDs[$0.. Twitter.Response.Content? in +// let response = try? await Twitter.API.V2.Lookup.statuses( +// session: self.session, +// query: .init(statusIDs: Array(chunk)), +// authorization: authenticationContext.authorization +// ) +// return response +// } +// +// return _responses.compactMap { $0 } +// } +// +// public func twitterBatchLookupV2( +// statusIDs: [Twitter.Entity.V2.Tweet.ID], +// authenticationContext: TwitterAuthenticationContext +// ) async throws -> TwitterBatchLookupResponseV2 { +// let responses = await twitterBatchLookupResponsesV2( +// statusIDs: statusIDs, +// authenticationContext: authenticationContext +// ) +// +// var tweets: [Twitter.Entity.V2.Tweet] = [] +// var users: [Twitter.Entity.V2.User] = [] +// var media: [Twitter.Entity.V2.Media] = [] +// var places: [Twitter.Entity.V2.Place] = [] +// var polls: [Twitter.Entity.V2.Tweet.Poll] = [] +// +// for response in responses { +// if let value = response.value.data { +// tweets.append(contentsOf: value) +// } +// if let value = response.value.includes?.tweets { +// tweets.append(contentsOf: value) +// } +// if let value = response.value.includes?.users { +// users.append(contentsOf: value) +// } +// if let value = response.value.includes?.media { +// media.append(contentsOf: value) +// } +// if let value = response.value.includes?.places { +// places.append(contentsOf: value) +// } +// if let value = response.value.includes?.polls { +// polls.append(contentsOf: value) +// } +// } +// +// let dictionary = Twitter.Response.V2.DictContent( +// tweets: tweets, +// users: users, +// media: media, +// places: places, +// polls: polls +// ) +// +// return .init(dictionary: dictionary) +// } +// +//} + diff --git a/TwidereSDK/Sources/TwidereCore/Service/AuthenticationService.swift b/TwidereSDK/Sources/TwidereCore/Service/AuthenticationService.swift index c0a15737..1f94e18b 100644 --- a/TwidereSDK/Sources/TwidereCore/Service/AuthenticationService.swift +++ b/TwidereSDK/Sources/TwidereCore/Service/AuthenticationService.swift @@ -27,9 +27,6 @@ public class AuthenticationService: NSObject { // output @Published public var authenticationIndexes: [AuthenticationIndex] = [] - public let activeAuthenticationIndex = CurrentValueSubject(nil) - - @Published public var activeAuthenticationContext: AuthenticationContext? = nil public init( managedObjectContext: NSManagedObjectContext, @@ -93,26 +90,26 @@ public class AuthenticationService: NSObject { // .store(in: &disposeBag) // bind activeAuthenticationIndex - $authenticationIndexes - .map { $0.sorted(by: { $0.activeAt > $1.activeAt }).first } - .assign(to: \.value, on: activeAuthenticationIndex) - .store(in: &disposeBag) - - // bind activeAuthenticationContext - activeAuthenticationIndex - .map { authenticationIndex -> AuthenticationContext? in - guard let authenticationIndex = authenticationIndex else { return nil } - guard let authenticationContext = AuthenticationContext(authenticationIndex: authenticationIndex, secret: appSecret.secret) else { return nil } - return authenticationContext - } - .assign(to: &$activeAuthenticationContext) - - do { - try authenticationIndexFetchedResultsController.performFetch() - authenticationIndexes = authenticationIndexFetchedResultsController.fetchedObjects ?? [] - } catch { - assertionFailure(error.localizedDescription) - } +// $authenticationIndexes +// .map { $0.sorted(by: { $0.activeAt > $1.activeAt }).first } +// .assign(to: \.value, on: activeAuthenticationIndex) +// .store(in: &disposeBag) +// +// // bind activeAuthenticationContext +// activeAuthenticationIndex +// .map { authenticationIndex -> AuthenticationContext? in +// guard let authenticationIndex = authenticationIndex else { return nil } +// guard let authenticationContext = AuthenticationContext(authenticationIndex: authenticationIndex, secret: appSecret.secret) else { return nil } +// return authenticationContext +// } +// .assign(to: &$activeAuthenticationContext) +// +// do { +// try authenticationIndexFetchedResultsController.performFetch() +// authenticationIndexes = authenticationIndexFetchedResultsController.fetchedObjects ?? [] +// } catch { +// assertionFailure(error.localizedDescription) +// } } } @@ -186,7 +183,7 @@ extension AuthenticationService { // MARK: - NSFetchedResultsControllerDelegate extension AuthenticationService: NSFetchedResultsControllerDelegate { - + public func controllerWillChangeContent(_ controller: NSFetchedResultsController) { os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) } @@ -199,5 +196,5 @@ extension AuthenticationService: NSFetchedResultsControllerDelegate { assertionFailure() } } - + } diff --git a/TwidereSDK/Sources/TwidereCore/Service/MastodonEmojiService+EmojiViewModel+State.swift b/TwidereSDK/Sources/TwidereCore/Service/MastodonEmojiService+EmojiViewModel+State.swift index 52ae2e9d..130b2975 100644 --- a/TwidereSDK/Sources/TwidereCore/Service/MastodonEmojiService+EmojiViewModel+State.swift +++ b/TwidereSDK/Sources/TwidereCore/Service/MastodonEmojiService+EmojiViewModel+State.swift @@ -8,7 +8,6 @@ import os.log import Foundation import GameplayKit -import TwidereCommon import MastodonSDK extension MastodonEmojiService.EmojiViewModel { diff --git a/TwidereSDK/Sources/TwidereCore/Service/NotificationService+NotificationSubscriber.swift b/TwidereSDK/Sources/TwidereCore/Service/NotificationService+NotificationSubscriber.swift index b990fd9a..f0ed7187 100644 --- a/TwidereSDK/Sources/TwidereCore/Service/NotificationService+NotificationSubscriber.swift +++ b/TwidereSDK/Sources/TwidereCore/Service/NotificationService+NotificationSubscriber.swift @@ -12,7 +12,6 @@ import CoreData import CoreDataStack import MastodonSDK import TwidereCommon -import CoreData public struct NotificationSubject { public let fcmToken: String? @@ -205,7 +204,7 @@ extension NotificationService.MastodonNotificationSubscriber { poll: true, createdAt: now, updatedAt: now, - mentionPreference: MastodonNotificationSubscription.MentionPreference() + mentionPreferenceTransient: MastodonNotificationSubscription.MentionPreference() ), relationship: .init(authentication: authentication) ) diff --git a/TwidereSDK/Sources/TwidereCore/Service/NotificationService.swift b/TwidereSDK/Sources/TwidereCore/Service/NotificationService.swift index 08703fe5..71c522d1 100644 --- a/TwidereSDK/Sources/TwidereCore/Service/NotificationService.swift +++ b/TwidereSDK/Sources/TwidereCore/Service/NotificationService.swift @@ -51,67 +51,69 @@ final public actor NotificationService { self.api = apiService self.authenticationService = authenticationService self.appSecret = appSecret + // end init + } + +} + +extension NotificationService { + func registerNotification() { + guard let authenticationService = authenticationService else { return } // request notification permission if needs - // register notification subscriber - authenticationService.$authenticationIndexes - .sink { [weak self] authenticationIndexes in - guard let self = self else { return } - - // request permission when sign-in account - Task { - if !authenticationIndexes.isEmpty { - await self.requestNotificationPermission() - } - } // end Task - - Task { - let authenticationContexts = authenticationIndexes.compactMap { authenticationIndex in - AuthenticationContext(authenticationIndex: authenticationIndex, secret: self.appSecret.secret) - } - await self.updateSubscribers(authenticationContexts) - } // end Task + Task { + let isEmpty = authenticationService.authenticationIndexes.isEmpty + if !isEmpty { + await self.requestNotificationPermission() } - .store(in: &disposeBag) // FIXME: how to use disposeBag in actor under Swift 6 ?? + } // end Task - Publishers.CombineLatest( - authenticationService.$authenticationIndexes, - applicationIconBadgeNeedsUpdate - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] authenticationIndexes, _ in - guard let self = self else { return } - - let authenticationContexts = authenticationIndexes.compactMap { authenticationIndex in - AuthenticationContext(authenticationIndex: authenticationIndex, secret: self.appSecret.secret) - } - - var count = 0 - for authenticationContext in authenticationContexts { - switch authenticationContext { - case .twitter: - continue - case .mastodon(let authenticationContext): - let accessToken = authenticationContext.authorization.accessToken - let _count = UserDefaults.shared.getNotificationCountWithAccessToken(accessToken: accessToken) - count += _count + // register notification subscriber + Task { + let managedObjectContext = authenticationService.managedObjectContext + let authenticationContexts: [AuthenticationContext] = await managedObjectContext.perform { + let authenticationContexts = authenticationService.authenticationIndexes.compactMap { authenticationIndex in + AuthenticationContext(authenticationIndex: authenticationIndex, secret: self.appSecret.secret) } + return authenticationContexts } + await self.updateSubscribers(authenticationContexts) + } // end Task + } + + func updateApplicationIconBadge() async { + guard let authenticationService = authenticationService else { return } - UserDefaults.shared.notificationBadgeCount = count - let _count = count - Task { - await self.updateApplicationIconBadge(count: _count) + let managedObjectContext = authenticationService.managedObjectContext + let authenticationContexts: [AuthenticationContext] = await managedObjectContext.perform { + let authenticationContexts = authenticationService.authenticationIndexes.compactMap { authenticationIndex in + AuthenticationContext(authenticationIndex: authenticationIndex, secret: self.appSecret.secret) + } + return authenticationContexts + } + + var count = 0 + for authenticationContext in authenticationContexts { + switch authenticationContext { + case .twitter: + continue + case .mastodon(let authenticationContext): + let accessToken = authenticationContext.authorization.accessToken + let _count = UserDefaults.shared.getNotificationCountWithAccessToken(accessToken: accessToken) + count += _count } - self.unreadNotificationCountDidUpdate.send() } - .store(in: &disposeBag) + + UserDefaults.shared.notificationBadgeCount = count + let _count = count + Task { + await self.updateApplicationIconBadge(count: _count) + } + self.unreadNotificationCountDidUpdate.send() } - } extension NotificationService { - public nonisolated func unreadApplicationShortcutItems() async -> [UIApplicationShortcutItem] { guard let authenticationService = await self.authenticationService else { return [] } let managedObjectContext = authenticationService.managedObjectContext @@ -123,14 +125,14 @@ extension NotificationService { return authenticationIndex.mastodonAuthentication?.userAccessToken }() guard let accessToken = _accessToken else { continue} - + let count = UserDefaults.shared.getNotificationCountWithAccessToken(accessToken: accessToken) guard count > 0 else { continue } - + guard let user = authenticationIndex.user else { continue} let title = "@\(user.username)" let subtitle = L10n.Count.notification(count) - + let item = UIApplicationShortcutItem( type: NotificationService.unreadShortcutItemIdentifier, localizedTitle: title, @@ -145,15 +147,12 @@ extension NotificationService { return items } } - } extension NotificationService { - public func clearNotificationCountForActiveUser() { - guard let authenticationService = self.authenticationService else { return } - guard let authenticationContext = authenticationService.activeAuthenticationContext else { return } - switch authenticationContext { + public func clearNotificationCountForUser(authContext: AuthContext) { + switch authContext.authenticationContext { case .twitter: return case .mastodon(let authenticationContext): diff --git a/TwidereSDK/Sources/TwidereCore/Service/PhotoLibraryService.swift b/TwidereSDK/Sources/TwidereCore/Service/PhotoLibraryService.swift index 999a25de..a1852407 100644 --- a/TwidereSDK/Sources/TwidereCore/Service/PhotoLibraryService.swift +++ b/TwidereSDK/Sources/TwidereCore/Service/PhotoLibraryService.swift @@ -11,7 +11,6 @@ import UIKit import Photos import Alamofire import AlamofireImage -import TwidereCommon import SwiftMessages import Kingfisher diff --git a/TwidereSDK/Sources/TwidereCore/Service/ThemeService.swift b/TwidereSDK/Sources/TwidereCore/Service/ThemeService.swift index d2027e72..74ece2a1 100644 --- a/TwidereSDK/Sources/TwidereCore/Service/ThemeService.swift +++ b/TwidereSDK/Sources/TwidereCore/Service/ThemeService.swift @@ -37,21 +37,11 @@ public final class ThemeService { UINavigationBar.appearance().compactScrollEdgeAppearance = appearance // set tab bar appearance - let tabBarAppearance = UITabBarAppearance() - tabBarAppearance.configureWithDefaultBackground() - tabBarAppearance.stackedLayoutAppearance = { - let tabBarItemAppearance = UITabBarItemAppearance() - tabBarItemAppearance.selected.titleTextAttributes = [.foregroundColor: UIColor.clear] - tabBarItemAppearance.focused.titleTextAttributes = [.foregroundColor: UIColor.clear] - tabBarItemAppearance.normal.titleTextAttributes = [.foregroundColor: UIColor.clear] - tabBarItemAppearance.disabled.titleTextAttributes = [.foregroundColor: UIColor.clear] - return tabBarItemAppearance - }() + let tabBarAppearance = ThemeService.setupTabBarAppearance() UITabBar.appearance().standardAppearance = tabBarAppearance UITabBar.appearance().scrollEdgeAppearance = tabBarAppearance - // UITabBar.appearance().barTintColor = theme.tabBarBackgroundColor } - + } extension Theme { @@ -68,3 +58,21 @@ extension Theme { } } } + +extension ThemeService { + public static func setupTabBarAppearance() -> UITabBarAppearance { + let tabBarAppearance = UITabBarAppearance() + tabBarAppearance.configureWithDefaultBackground() + tabBarAppearance.stackedLayoutAppearance = { + let tabBarItemAppearance = UITabBarItemAppearance() + if !UserDefaults.shared.preferredTabBarLabelDisplay { + tabBarItemAppearance.selected.titleTextAttributes = [.foregroundColor: UIColor.clear] + tabBarItemAppearance.focused.titleTextAttributes = [.foregroundColor: UIColor.clear] + tabBarItemAppearance.normal.titleTextAttributes = [.foregroundColor: UIColor.clear] + tabBarItemAppearance.disabled.titleTextAttributes = [.foregroundColor: UIColor.clear] + } + return tabBarItemAppearance + }() + return tabBarAppearance + } +} diff --git a/TwidereSDK/Sources/TwidereCore/Service/TrendService.swift b/TwidereSDK/Sources/TwidereCore/Service/TrendService.swift index 7841b62e..3bf926bf 100644 --- a/TwidereSDK/Sources/TwidereCore/Service/TrendService.swift +++ b/TwidereSDK/Sources/TwidereCore/Service/TrendService.swift @@ -87,7 +87,7 @@ extension TrendService { for data in response.value { guard let location = data.locations.first else { continue } trendGroupRecords[.twitter(placeID: location.woeid)] = TrendGroup( - trends: data.trends.map { TrendObject.twitter(trend: $0) }, + trends: data.trends.map { TrendObject.twitter(trend: $0) }.removingDuplicates(), timestamp: data.asOf ) } @@ -97,7 +97,7 @@ extension TrendService { authenticationContext: authenticationContext ) trendGroupRecords[.mastodon(domain: domain)] = TrendGroup( - trends: response.value.map { TrendObject.mastodon(tag: $0) }, + trends: response.value.map { TrendObject.mastodon(tag: $0) }.removingDuplicates(), timestamp: response.networkDate ) logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch trends \(response.value.count) ") diff --git a/TwidereSDK/Sources/TwidereCore/State/AppContext.swift b/TwidereSDK/Sources/TwidereCore/State/AppContext.swift index 2cfce99b..e00f3e62 100644 --- a/TwidereSDK/Sources/TwidereCore/State/AppContext.swift +++ b/TwidereSDK/Sources/TwidereCore/State/AppContext.swift @@ -67,6 +67,42 @@ public class AppContext: ObservableObject { authenticationService: _authenticationService, appSecret: appSecret ) + + setupNotification() + setupStoreReview() + } + + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension AppContext { + private func setupNotification() { + authenticationService.$authenticationIndexes + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + Task { + await self.notificationService.registerNotification() + } // end Task + } + .store(in: &disposeBag) + + Publishers.CombineLatest( + authenticationService.$authenticationIndexes, + notificationService.applicationIconBadgeNeedsUpdate + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] _, _ in + guard let self = self else { return } + Task { + await self.notificationService.updateApplicationIconBadge() + } // end Task + } + .store(in: &disposeBag) } private func setupStoreReview() { @@ -103,8 +139,4 @@ public class AppContext: ObservableObject { } - deinit { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - } - } diff --git a/TwidereSDK/Sources/TwidereCore/State/AuthContext.swift b/TwidereSDK/Sources/TwidereCore/State/AuthContext.swift index f985704c..8faf7df7 100644 --- a/TwidereSDK/Sources/TwidereCore/State/AuthContext.swift +++ b/TwidereSDK/Sources/TwidereCore/State/AuthContext.swift @@ -6,15 +6,47 @@ // import Foundation +import Combine import CoreData import CoreDataStack +import TwidereCommon +import TwitterSDK + +public protocol AuthContextProvider { + var authContext: AuthContext { get } +} public class AuthContext { - + var disposeBag = Set() + + // authentication public let authenticationContext: AuthenticationContext public init(authenticationContext: AuthenticationContext) { self.authenticationContext = authenticationContext + // end init } + public convenience init?(authenticationIndex: AuthenticationIndex) { + let _authenticationContext = AuthenticationContext( + authenticationIndex: authenticationIndex, + secret: AppSecret.default.secret + ) + guard let authenticationContext = _authenticationContext else { + return nil + } + self.init(authenticationContext: authenticationContext) + } + +} + +#if DEBUG +extension AuthContext { + public static func mock(context: AppContext) -> AuthContext? { + let request = AuthenticationIndex.sortedFetchRequest + let _authenticationIndex = try? context.managedObjectContext.fetch(request).first + let _authContext = _authenticationIndex.flatMap { AuthContext(authenticationIndex: $0) } + return _authContext + } } +#endif 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/RelationshipViewModel.swift b/TwidereSDK/Sources/TwidereCore/ViewModel/RelationshipViewModel.swift index 32200cde..8cd9d8e4 100644 --- a/TwidereSDK/Sources/TwidereCore/ViewModel/RelationshipViewModel.swift +++ b/TwidereSDK/Sources/TwidereCore/ViewModel/RelationshipViewModel.swift @@ -109,6 +109,7 @@ public final class RelationshipViewModel { $me, relationshipUpdatePublisher ) + .receive(on: DispatchQueue.main) .sink { [weak self] user, me, _ in guard let self = self else { return } self.update(user: user, me: me) diff --git a/TwidereSDK/Sources/TwidereCore/ViewModel/Status/StatusFetchViewModel+Timeline+Home.swift b/TwidereSDK/Sources/TwidereCore/ViewModel/Status/StatusFetchViewModel+Timeline+Home.swift index a2b0e3ad..1682e3c7 100644 --- a/TwidereSDK/Sources/TwidereCore/ViewModel/Status/StatusFetchViewModel+Timeline+Home.swift +++ b/TwidereSDK/Sources/TwidereCore/ViewModel/Status/StatusFetchViewModel+Timeline+Home.swift @@ -94,34 +94,40 @@ extension StatusFetchViewModel.Timeline.Home { public static func fetch(api: APIService, input: Input) async throws -> StatusFetchViewModel.Timeline.Output { switch input { case .twitter(let fetchContext): - let responses = try await api.twitterHomeTimeline( - query: .init( - sinceID: fetchContext.sinceID, - untilID: fetchContext.untilID, - paginationToken: nil, - maxResults: fetchContext.maxResults ?? 100 - ), - authenticationContext: fetchContext.authenticationContext - ) - let nextInput: Input? = { - guard let last = responses.last, - last.value.meta.nextToken != nil, - let oldestID = last.value.meta.oldestID - else { return nil } - let fetchContext = fetchContext.map(untilID: oldestID) - return .twitter(fetchContext) - }() - return .init( - result: { - let statuses = responses - .map { $0.value.data } - .compactMap{ $0 } - .flatMap { $0 } - return .twitterV2(statuses) - }(), - backInput: nil, - nextInput: nextInput.flatMap { .home($0) } - ) + do { + let responses = try await api.twitterHomeTimeline( + query: .init( + sinceID: fetchContext.sinceID, + untilID: fetchContext.untilID, + paginationToken: nil, + maxResults: fetchContext.maxResults ?? 100, + onlyMedia: fetchContext.filter.rule.contains(.onlyMedia) + ), + authenticationContext: fetchContext.authenticationContext + ) + let nextInput: Input? = { + guard let last = responses.last, + last.value.meta.nextToken != nil, + let oldestID = last.value.meta.oldestID + else { return nil } + let fetchContext = fetchContext.map(untilID: oldestID) + return .twitter(fetchContext) + }() + return .init( + result: { + let statuses = responses + .map { $0.value.data } + .compactMap{ $0 } + .flatMap { $0 } + return .twitterV2(statuses) + }(), + backInput: nil, + nextInput: nextInput.flatMap { .home($0) } + ) + } catch { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(error.localizedDescription)") + throw error + } case .mastodon(let fetchContext): let authenticationContext = fetchContext.authenticationContext let responses = try await api.mastodonHomeTimeline( diff --git a/TwidereSDK/Sources/TwidereCore/ViewModel/Status/StatusFetchViewModel+Timeline+List.swift b/TwidereSDK/Sources/TwidereCore/ViewModel/Status/StatusFetchViewModel+Timeline+List.swift index f4fd0517..9b0f4c26 100644 --- a/TwidereSDK/Sources/TwidereCore/ViewModel/Status/StatusFetchViewModel+Timeline+List.swift +++ b/TwidereSDK/Sources/TwidereCore/ViewModel/Status/StatusFetchViewModel+Timeline+List.swift @@ -7,6 +7,7 @@ import os.log import Foundation +import CoreData import CoreDataStack import TwitterSDK import MastodonSDK @@ -25,6 +26,7 @@ extension StatusFetchViewModel.Timeline.List { } public struct TwitterFetchContext: Hashable { + public let managedObjectContext: NSManagedObjectContext public let authenticationContext: TwitterAuthenticationContext public let list: ManagedObjectRecord public let paginationToken: String? @@ -35,6 +37,7 @@ extension StatusFetchViewModel.Timeline.List { public var needsAPIFallback = false public init( + managedObjectContext: NSManagedObjectContext, authenticationContext: TwitterAuthenticationContext, list: ManagedObjectRecord, paginationToken: String?, @@ -42,6 +45,7 @@ extension StatusFetchViewModel.Timeline.List { maxResults: Int?, filter: StatusFetchViewModel.Timeline.Filter ) { + self.managedObjectContext = managedObjectContext self.authenticationContext = authenticationContext self.list = list self.paginationToken = paginationToken @@ -52,6 +56,7 @@ extension StatusFetchViewModel.Timeline.List { func map(paginationToken: String) -> TwitterFetchContext { return TwitterFetchContext( + managedObjectContext: managedObjectContext, authenticationContext: authenticationContext, list: list, paginationToken: paginationToken, @@ -63,6 +68,7 @@ extension StatusFetchViewModel.Timeline.List { func map(maxID: Twitter.Entity.Tweet.ID) -> TwitterFetchContext { return TwitterFetchContext( + managedObjectContext: managedObjectContext, authenticationContext: authenticationContext, list: list, paginationToken: paginationToken, @@ -125,7 +131,7 @@ extension StatusFetchViewModel.Timeline.List { extension StatusFetchViewModel.Timeline.List { enum TwitterResponse { - case v2(Twitter.Response.Content) + case v2(Twitter.Response.Content) case v1(Twitter.Response.Content<[Twitter.Entity.Tweet]>) func filter(fetchContext: TwitterFetchContext) -> StatusFetchViewModel.Result { @@ -143,8 +149,8 @@ extension StatusFetchViewModel.Timeline.List { func backInput(fetchContext: TwitterFetchContext) -> Input? { switch self { case .v2(let response): - guard let nextToken = response.value.meta.previousToken else { return nil } - let fetchContext = fetchContext.map(paginationToken: nextToken) + guard let previousToken = response.value.meta.previousToken else { return nil } + let fetchContext = fetchContext.map(paginationToken: previousToken) return .twitter(fetchContext) case .v1: return nil @@ -184,25 +190,18 @@ extension StatusFetchViewModel.Timeline.List { query: query, authenticationContext: fetchContext.authenticationContext ) - return .v2(response) - } catch let error as Twitter.API.Error.ResponseError where error.twitterAPIError == .rateLimitExceeded { - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Rate Limit] fallback to v1") - let managedObjectContext = api.backgroundManagedObjectContext - let _listID: TwitterList.ID? = await managedObjectContext.perform { - guard let list = fetchContext.list.object(in: managedObjectContext) else { return nil } - return list.id - } - guard let listID = _listID else { - throw AppError.implicit(.badRequest) + + let data = response.value.data ?? [] + if data.isEmpty { + try await fetchContext.managedObjectContext.perform { + guard let list = fetchContext.list.object(in: fetchContext.managedObjectContext) else { return } + if list.private { + throw EmptyState.unableToAccess(reason: "The list is private") + } + } } - let response = try await api.twitterListStatusesV1( - query: .init( - id: listID, - maxID: fetchContext.maxID - ), - authenticationContext: fetchContext.authenticationContext - ) - return .v1(response) + + return .v2(response) } catch { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch failure: \(error.localizedDescription)") throw error diff --git a/TwidereSDK/Sources/TwidereCore/ViewModel/Status/StatusFetchViewModel+Timeline+Search.swift b/TwidereSDK/Sources/TwidereCore/ViewModel/Status/StatusFetchViewModel+Timeline+Search.swift index 3354d29d..9160ef80 100644 --- a/TwidereSDK/Sources/TwidereCore/ViewModel/Status/StatusFetchViewModel+Timeline+Search.swift +++ b/TwidereSDK/Sources/TwidereCore/ViewModel/Status/StatusFetchViewModel+Timeline+Search.swift @@ -115,7 +115,7 @@ extension StatusFetchViewModel.Timeline.Search { extension StatusFetchViewModel.Timeline.Search { enum TwitterResponse { - case v2(Twitter.Response.Content) + case v2(Twitter.Response.Content) case v1(Twitter.Response.Content) func filter(fetchContext: TwitterFetchContext) -> StatusFetchViewModel.Result { @@ -134,6 +134,9 @@ extension StatusFetchViewModel.Timeline.Search { switch self { case .v2(let response): guard let nextToken = response.value.meta.nextToken else { return nil } + guard fetchContext.nextToken != nextToken else { return nil } + guard let data = response.value.data, data.count > 1 else { return nil } + let fetchContext = fetchContext.map(untilID: response.value.meta.oldestID, nextToken: nextToken) return .twitter(fetchContext) case .v1(let response): @@ -177,12 +180,13 @@ extension StatusFetchViewModel.Timeline.Search { startTime: nil, nextToken: fetchContext.nextToken ) + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Search] Searching…") let response = try await api.searchTwitterStatus( query: query, authenticationContext: fetchContext.authenticationContext ) return .v2(response) - } catch let error as Twitter.API.Error.ResponseError where error.twitterAPIError == .rateLimitExceeded { + } catch let error as Twitter.API.Error.ResponseError where error.twitterAPIError == .rateLimitExceeded || error.httpResponseStatus == .notFound { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Rate Limit] fallback to v1") let queryText: String = try { let searchText = fetchContext.searchText.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/TwidereSDK/Sources/TwidereCore/ViewModel/Status/StatusFetchViewModel+Timeline+User.swift b/TwidereSDK/Sources/TwidereCore/ViewModel/Status/StatusFetchViewModel+Timeline+User.swift index 23981b60..d4a47f57 100644 --- a/TwidereSDK/Sources/TwidereCore/ViewModel/Status/StatusFetchViewModel+Timeline+User.swift +++ b/TwidereSDK/Sources/TwidereCore/ViewModel/Status/StatusFetchViewModel+Timeline+User.swift @@ -9,6 +9,8 @@ import os.log import Foundation import TwitterSDK import MastodonSDK +import CoreData +import CoreDataStack extension StatusFetchViewModel.Timeline { public enum User { } @@ -26,6 +28,7 @@ extension StatusFetchViewModel.Timeline.User { public struct TwitterFetchContext: Hashable { public let authenticationContext: TwitterAuthenticationContext public let userID: Twitter.Entity.V2.User.ID + public let protected: Bool public let paginationToken: String? public let maxID: Twitter.Entity.V2.Tweet.ID? public let maxResults: Int? @@ -37,6 +40,7 @@ extension StatusFetchViewModel.Timeline.User { public init( authenticationContext: TwitterAuthenticationContext, userID: Twitter.Entity.V2.User.ID, + protected: Bool, paginationToken: String?, maxID: Twitter.Entity.V2.Tweet.ID?, maxResults: Int?, @@ -45,6 +49,7 @@ extension StatusFetchViewModel.Timeline.User { ) { self.authenticationContext = authenticationContext self.userID = userID + self.protected = protected self.paginationToken = paginationToken self.maxID = maxID self.maxResults = maxResults @@ -56,6 +61,7 @@ extension StatusFetchViewModel.Timeline.User { return TwitterFetchContext( authenticationContext: authenticationContext, userID: userID, + protected: protected, paginationToken: paginationToken, maxID: maxID, maxResults: maxResults, @@ -68,6 +74,7 @@ extension StatusFetchViewModel.Timeline.User { return TwitterFetchContext( authenticationContext: authenticationContext, userID: userID, + protected: protected, paginationToken: paginationToken, maxID: maxID, maxResults: maxResults, @@ -120,6 +127,7 @@ extension StatusFetchViewModel.Timeline.User { enum TwitterResponse { case v2(Twitter.Response.Content) case v1(Twitter.Response.Content<[Twitter.Entity.Tweet]>) + case local([TwitterStatus.ID]) func filter(fetchContext: TwitterFetchContext) -> StatusFetchViewModel.Result { switch self { @@ -130,13 +138,17 @@ extension StatusFetchViewModel.Timeline.User { case .v1(let response): let result = response.value.filter(fetchContext.filter.isIncluded) return .twitter(result) + case .local(let statusIDs): + return .twitterIDs(statusIDs) } } func nextInput(fetchContext: TwitterFetchContext) -> Input? { switch self { case .v2(let response): + guard response.value.meta.resultCount > 0 else { return nil } guard let nextToken = response.value.meta.nextToken else { return nil } + guard nextToken != fetchContext.paginationToken else { return nil } let fetchContext = fetchContext.map(paginationToken: nextToken) return .twitter(fetchContext) case .v1(let response): @@ -145,6 +157,8 @@ extension StatusFetchViewModel.Timeline.User { var fetchContext = fetchContext.map(maxID: maxID) fetchContext.needsAPIFallback = true return .twitter(fetchContext) + case .local: + return nil } } } @@ -156,68 +170,46 @@ extension StatusFetchViewModel.Timeline.User { switch fetchContext.timelineKind { case .status, .media: do { - guard !fetchContext.needsAPIFallback else { - throw Twitter.API.Error.ResponseError(httpResponseStatus: .ok, twitterAPIError: .rateLimitExceeded) + guard !fetchContext.protected else { + throw EmptyState.unableToAccess() } + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [UserTimeline] fetch user timeline: userID[\(fetchContext.userID)] cursor[\(fetchContext.paginationToken ?? "")]") let response = try await api.twitterUserTimeline( userID: fetchContext.userID, query: .init( sinceID: nil, untilID: nil, paginationToken: fetchContext.paginationToken, - maxResults: fetchContext.maxResults ?? 20 + maxResults: fetchContext.maxResults ?? 20, + onlyMedia: fetchContext.filter.rule.contains(.onlyMedia) ), authenticationContext: fetchContext.authenticationContext ) return .v2(response) - } catch let error as Twitter.API.Error.ResponseError where error.twitterAPIError == .rateLimitExceeded { - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Rate Limit] fallback to v1") - let response = try await api.twitterUserTimelineV1( - query: .init( - count: fetchContext.maxResults ?? 20, - userID: fetchContext.userID, - maxID: fetchContext.maxID, - sinceID: nil, - excludeReplies: false, - query: nil - ), - authenticationContext: fetchContext.authenticationContext - ) - return .v1(response) } catch { - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch failure: \(error.localizedDescription)") + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [UserTimeline] fetch failure: \(error.localizedDescription)") throw error } case .like: do { - guard !fetchContext.needsAPIFallback else { - throw Twitter.API.Error.ResponseError(httpResponseStatus: .ok, twitterAPIError: .rateLimitExceeded) + if fetchContext.paginationToken != nil { + throw AppError.implicit(.badRequest) } - let response = try await api.twitterLikeTimeline( - userID: fetchContext.userID, - query: .init( - sinceID: nil, - untilID: nil, - paginationToken: fetchContext.paginationToken, - maxResults: fetchContext.maxResults ?? 20 - ), - authenticationContext: fetchContext.authenticationContext - ) - return .v2(response) - } catch let error as Twitter.API.Error.ResponseError where error.twitterAPIError == .rateLimitExceeded { - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Rate Limit] fallback to v1") - let response = try await api.twitterLikeTimelineV1( - query: .init( - count: fetchContext.maxResults ?? 20, - userID: fetchContext.userID, - maxID: fetchContext.maxID, - sinceID: nil, - excludeReplies: false, - query: nil - ), - authenticationContext: fetchContext.authenticationContext - ) - return .v1(response) + + let managedObjectContext = api.coreDataStack.persistentContainer.viewContext + let statusIDs: [TwitterStatus.ID] = try await managedObjectContext.perform { + let userRequest = TwitterUser.sortedFetchRequest + userRequest.predicate = TwitterUser.predicate(id: fetchContext.userID) + guard let user = try managedObjectContext.fetch(userRequest).first else { + throw AppError.implicit(.badRequest) + } + let statusIDs = user.like.map { $0.id } + let statusRequest = TwitterStatus.sortedFetchRequest + statusRequest.predicate = TwitterStatus.predicate(ids: statusIDs) + let results = try managedObjectContext.fetch(statusRequest) + return results.map { $0.id } + } + return .local(statusIDs) } catch { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch failure: \(error.localizedDescription)") throw error diff --git a/TwidereSDK/Sources/TwidereCore/ViewModel/Status/StatusFetchViewModel+Timeline.swift b/TwidereSDK/Sources/TwidereCore/ViewModel/Status/StatusFetchViewModel+Timeline.swift index 38f48e09..c62fcd5c 100644 --- a/TwidereSDK/Sources/TwidereCore/ViewModel/Status/StatusFetchViewModel+Timeline.swift +++ b/TwidereSDK/Sources/TwidereCore/ViewModel/Status/StatusFetchViewModel+Timeline.swift @@ -7,6 +7,7 @@ import os.log import CoreData +import Combine import Foundation import TwitterSDK import MastodonSDK @@ -17,6 +18,8 @@ extension StatusFetchViewModel { } extension StatusFetchViewModel.Timeline { + public static let logger = Logger(subsystem: "StatusFetchViewModel", category: "Timeline") + public enum Kind { case home case `public`(isLocal: Bool) @@ -47,18 +50,27 @@ extension StatusFetchViewModel.Timeline.Kind { public class UserTimelineContext { public let timelineKind: TimelineKind + @Published public var protected: Bool? @Published public var userIdentifier: UserIdentifier? public init( timelineKind: TimelineKind, - userIdentifier: Published.Publisher? + protected protectedPublisher: Published.Publisher?, + userIdentifier userIdentifierPublisher: Published.Publisher? ) { self.timelineKind = timelineKind - - if let userIdentifier = userIdentifier { - userIdentifier.assign(to: &self.$userIdentifier) - - } + protectedPublisher?.assign(to: &$protected) + userIdentifierPublisher?.assign(to: &$userIdentifier) + } + + public init( + timelineKind: TimelineKind, + protected: Bool, + userIdentifier: UserIdentifier? + ) { + self.timelineKind = timelineKind + self.protected = protected + self.userIdentifier = userIdentifier } public enum TimelineKind { @@ -372,6 +384,7 @@ extension StatusFetchViewModel.Timeline { throw AppError.implicit(.internal(reason: "Use invalid list record for Twitter list status lookup")) } return .list(.twitter(.init( + managedObjectContext: fetchContext.managedObjectContext, authenticationContext: authenticationContext, list: record, paginationToken: nil, @@ -438,6 +451,7 @@ extension StatusFetchViewModel.Timeline { return .user(.twitter(.init( authenticationContext: authenticationContext, userID: userIdentifier.id, + protected: userTimelineContext.protected ?? false, paginationToken: nil, maxID: nil, maxResults: nil, @@ -491,19 +505,24 @@ extension StatusFetchViewModel.Timeline { api: APIService, input: Input ) async throws -> Output { - switch input { - case .home(let input): - return try await Home.fetch(api: api, input: input) - case .public(let input): - return try await Public.fetch(api: api, input: input) - case .hashtag(let input): - return try await Hashtag.fetch(api: api, input: input) - case .list(let input): - return try await List.fetch(api: api, input: input) - case .search(let input): - return try await Search.fetch(api: api, input: input) - case .user(let input): - return try await User.fetch(api: api, input: input) + do { + switch input { + case .home(let input): + return try await Home.fetch(api: api, input: input) + case .public(let input): + return try await Public.fetch(api: api, input: input) + case .hashtag(let input): + return try await Hashtag.fetch(api: api, input: input) + case .list(let input): + return try await List.fetch(api: api, input: input) + case .search(let input): + return try await Search.fetch(api: api, input: input) + case .user(let input): + return try await User.fetch(api: api, input: input) + } + } catch { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch \(String(describing: input)) failure…") + throw error } } diff --git a/TwidereSDK/Sources/TwidereCore/ViewModel/Status/StatusFetchViewModel.swift b/TwidereSDK/Sources/TwidereCore/ViewModel/Status/StatusFetchViewModel.swift index 758a9be5..05f830d4 100644 --- a/TwidereSDK/Sources/TwidereCore/ViewModel/Status/StatusFetchViewModel.swift +++ b/TwidereSDK/Sources/TwidereCore/ViewModel/Status/StatusFetchViewModel.swift @@ -19,12 +19,14 @@ public enum StatusFetchViewModel { public enum Result { case twitter([Twitter.Entity.Tweet]) // v1 case twitterV2([Twitter.Entity.V2.Tweet]) // v2 + case twitterIDs([TwitterStatus.ID]) case mastodon([Mastodon.Entity.Status]) public var count: Int { switch self { case .twitter(let array): return array.count case .twitterV2(let array): return array.count + case .twitterIDs(let array): return array.count case .mastodon(let array): return array.count } } diff --git a/TwidereSDK/Sources/TwidereCore/ViewModel/User/UserFetchViewModel+Friendship.swift b/TwidereSDK/Sources/TwidereCore/ViewModel/User/UserFetchViewModel+Friendship.swift index 91f20bfa..be21f120 100644 --- a/TwidereSDK/Sources/TwidereCore/ViewModel/User/UserFetchViewModel+Friendship.swift +++ b/TwidereSDK/Sources/TwidereCore/ViewModel/User/UserFetchViewModel+Friendship.swift @@ -101,7 +101,7 @@ extension UserFetchViewModel.Friendship { case .twitter(let fetchContext): let query = Twitter.API.V2.User.Follow.FriendshipListQuery( userID: fetchContext.userID, - maxResults: fetchContext.maxResults ?? (fetchContext.paginationToken == nil ? 200 : 1000), + maxResults: fetchContext.maxResults ?? (fetchContext.paginationToken == nil ? 20 : 200), paginationToken: fetchContext.paginationToken ) let response = try await { () -> Twitter.Response.Content in diff --git a/TwidereSDK/Sources/TwidereLocalization/Generated/Strings.swift b/TwidereSDK/Sources/TwidereLocalization/Generated/Strings.swift index 9b34eb7a..b0f79826 100644 --- a/TwidereSDK/Sources/TwidereLocalization/Generated/Strings.swift +++ b/TwidereSDK/Sources/TwidereLocalization/Generated/Strings.swift @@ -3,761 +3,783 @@ import Foundation -// swiftlint:disable superfluous_disable_command file_length implicit_return +// swiftlint:disable superfluous_disable_command file_length implicit_return prefer_self_in_static_references // MARK: - Strings // swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces public enum L10n { - public enum Accessibility { public enum Common { /// Back - public static let back = L10n.tr("Localizable", "Accessibility.Common.Back") + public static let back = L10n.tr("Localizable", "Accessibility.Common.Back", fallback: "Back") /// Close - public static let close = L10n.tr("Localizable", "Accessibility.Common.Close") + public static let close = L10n.tr("Localizable", "Accessibility.Common.Close", fallback: "Close") /// Done - public static let done = L10n.tr("Localizable", "Accessibility.Common.Done") + public static let done = L10n.tr("Localizable", "Accessibility.Common.Done", fallback: "Done") /// More - public static let more = L10n.tr("Localizable", "Accessibility.Common.More") + public static let more = L10n.tr("Localizable", "Accessibility.Common.More", fallback: "More") /// Network Image - public static let networkImage = L10n.tr("Localizable", "Accessibility.Common.NetworkImage") + public static let networkImage = L10n.tr("Localizable", "Accessibility.Common.NetworkImage", fallback: "Network Image") public enum Logo { /// Github Logo - public static let github = L10n.tr("Localizable", "Accessibility.Common.Logo.Github") + public static let github = L10n.tr("Localizable", "Accessibility.Common.Logo.Github", fallback: "Github Logo") /// Mastodon Logo - public static let mastodon = L10n.tr("Localizable", "Accessibility.Common.Logo.Mastodon") + public static let mastodon = L10n.tr("Localizable", "Accessibility.Common.Logo.Mastodon", fallback: "Mastodon Logo") /// Telegram Logo - public static let telegram = L10n.tr("Localizable", "Accessibility.Common.Logo.Telegram") + public static let telegram = L10n.tr("Localizable", "Accessibility.Common.Logo.Telegram", fallback: "Telegram Logo") /// Twidere X logo - public static let twidere = L10n.tr("Localizable", "Accessibility.Common.Logo.Twidere") + public static let twidere = L10n.tr("Localizable", "Accessibility.Common.Logo.Twidere", fallback: "Twidere X logo") /// Twitter Logo - public static let twitter = L10n.tr("Localizable", "Accessibility.Common.Logo.Twitter") + public static let twitter = L10n.tr("Localizable", "Accessibility.Common.Logo.Twitter", fallback: "Twitter Logo") } public enum Status { /// Author avatar - public static let authorAvatar = L10n.tr("Localizable", "Accessibility.Common.Status.AuthorAvatar") + public static let authorAvatar = L10n.tr("Localizable", "Accessibility.Common.Status.AuthorAvatar", fallback: "Author avatar") /// Boosted - public static let boosted = L10n.tr("Localizable", "Accessibility.Common.Status.Boosted") + public static let boosted = L10n.tr("Localizable", "Accessibility.Common.Status.Boosted", fallback: "Boosted") /// Content Warning - public static let contentWarning = L10n.tr("Localizable", "Accessibility.Common.Status.ContentWarning") + public static let contentWarning = L10n.tr("Localizable", "Accessibility.Common.Status.ContentWarning", fallback: "Content Warning") /// Liked - public static let liked = L10n.tr("Localizable", "Accessibility.Common.Status.Liked") + public static let liked = L10n.tr("Localizable", "Accessibility.Common.Status.Liked", fallback: "Liked") /// Location - public static let location = L10n.tr("Localizable", "Accessibility.Common.Status.Location") + public static let location = L10n.tr("Localizable", "Accessibility.Common.Status.Location", fallback: "Location") /// Media - public static let media = L10n.tr("Localizable", "Accessibility.Common.Status.Media") + public static let media = L10n.tr("Localizable", "Accessibility.Common.Status.Media", fallback: "Media") /// Poll option - public static let pollOptionOrdinalPrefix = L10n.tr("Localizable", "Accessibility.Common.Status.PollOptionOrdinalPrefix") + public static let pollOptionOrdinalPrefix = L10n.tr("Localizable", "Accessibility.Common.Status.PollOptionOrdinalPrefix", fallback: "Poll option") /// Retweeted - public static let retweeted = L10n.tr("Localizable", "Accessibility.Common.Status.Retweeted") + public static let retweeted = L10n.tr("Localizable", "Accessibility.Common.Status.Retweeted", fallback: "Retweeted") public enum Actions { /// Hide content - public static let hideContent = L10n.tr("Localizable", "Accessibility.Common.Status.Actions.HideContent") + public static let hideContent = L10n.tr("Localizable", "Accessibility.Common.Status.Actions.HideContent", fallback: "Hide content") /// Hide media - public static let hideMedia = L10n.tr("Localizable", "Accessibility.Common.Status.Actions.HideMedia") + public static let hideMedia = L10n.tr("Localizable", "Accessibility.Common.Status.Actions.HideMedia", fallback: "Hide media") /// Like - public static let like = L10n.tr("Localizable", "Accessibility.Common.Status.Actions.Like") + public static let like = L10n.tr("Localizable", "Accessibility.Common.Status.Actions.Like", fallback: "Like") /// Menu - public static let menu = L10n.tr("Localizable", "Accessibility.Common.Status.Actions.Menu") + public static let menu = L10n.tr("Localizable", "Accessibility.Common.Status.Actions.Menu", fallback: "Menu") /// Reply - public static let reply = L10n.tr("Localizable", "Accessibility.Common.Status.Actions.Reply") + public static let reply = L10n.tr("Localizable", "Accessibility.Common.Status.Actions.Reply", fallback: "Reply") /// Retweet - public static let retweet = L10n.tr("Localizable", "Accessibility.Common.Status.Actions.Retweet") + public static let retweet = L10n.tr("Localizable", "Accessibility.Common.Status.Actions.Retweet", fallback: "Retweet") /// Reveal content - public static let revealContent = L10n.tr("Localizable", "Accessibility.Common.Status.Actions.RevealContent") + public static let revealContent = L10n.tr("Localizable", "Accessibility.Common.Status.Actions.RevealContent", fallback: "Reveal content") /// Reveal media - public static let revealMedia = L10n.tr("Localizable", "Accessibility.Common.Status.Actions.RevealMedia") + public static let revealMedia = L10n.tr("Localizable", "Accessibility.Common.Status.Actions.RevealMedia", fallback: "Reveal media") } } public enum Video { /// Play video - public static let play = L10n.tr("Localizable", "Accessibility.Common.Video.Play") + public static let play = L10n.tr("Localizable", "Accessibility.Common.Video.Play", fallback: "Play video") } } public enum Scene { public enum Compose { /// Add mention - public static let addMention = L10n.tr("Localizable", "Accessibility.Scene.Compose.AddMention") + public static let addMention = L10n.tr("Localizable", "Accessibility.Scene.Compose.AddMention", fallback: "Add mention") /// Open draft - public static let draft = L10n.tr("Localizable", "Accessibility.Scene.Compose.Draft") + public static let draft = L10n.tr("Localizable", "Accessibility.Scene.Compose.Draft", fallback: "Open draft") /// Add image - public static let image = L10n.tr("Localizable", "Accessibility.Scene.Compose.Image") + public static let image = L10n.tr("Localizable", "Accessibility.Scene.Compose.Image", fallback: "Add image") /// Send - public static let send = L10n.tr("Localizable", "Accessibility.Scene.Compose.Send") + public static let send = L10n.tr("Localizable", "Accessibility.Scene.Compose.Send", fallback: "Send") /// Thread mode - public static let thread = L10n.tr("Localizable", "Accessibility.Scene.Compose.Thread") + public static let thread = L10n.tr("Localizable", "Accessibility.Scene.Compose.Thread", fallback: "Thread mode") public enum Location { /// Disable location - public static let disable = L10n.tr("Localizable", "Accessibility.Scene.Compose.Location.Disable") + public static let disable = L10n.tr("Localizable", "Accessibility.Scene.Compose.Location.Disable", fallback: "Disable location") /// Enable location - public static let enable = L10n.tr("Localizable", "Accessibility.Scene.Compose.Location.Enable") + public static let enable = L10n.tr("Localizable", "Accessibility.Scene.Compose.Location.Enable", fallback: "Enable location") } public enum MediaInsert { /// Take Photo - public static let camera = L10n.tr("Localizable", "Accessibility.Scene.Compose.MediaInsert.Camera") + public static let camera = L10n.tr("Localizable", "Accessibility.Scene.Compose.MediaInsert.Camera", fallback: "Take Photo") /// Add GIF - public static let gif = L10n.tr("Localizable", "Accessibility.Scene.Compose.MediaInsert.Gif") + public static let gif = L10n.tr("Localizable", "Accessibility.Scene.Compose.MediaInsert.Gif", fallback: "Add GIF") /// Browse Library - public static let library = L10n.tr("Localizable", "Accessibility.Scene.Compose.MediaInsert.Library") + public static let library = L10n.tr("Localizable", "Accessibility.Scene.Compose.MediaInsert.Library", fallback: "Browse Library") /// Record Video - public static let recordVideo = L10n.tr("Localizable", "Accessibility.Scene.Compose.MediaInsert.RecordVideo") + public static let recordVideo = L10n.tr("Localizable", "Accessibility.Scene.Compose.MediaInsert.RecordVideo", fallback: "Record Video") } } public enum Gif { /// Search GIF - public static let search = L10n.tr("Localizable", "Accessibility.Scene.Gif.Search") + public static let search = L10n.tr("Localizable", "Accessibility.Scene.Gif.Search", fallback: "Search GIF") /// GIPHY - public static let title = L10n.tr("Localizable", "Accessibility.Scene.Gif.Title") + public static let title = L10n.tr("Localizable", "Accessibility.Scene.Gif.Title", fallback: "GIPHY") } public enum Home { /// Compose - public static let compose = L10n.tr("Localizable", "Accessibility.Scene.Home.Compose") + public static let compose = L10n.tr("Localizable", "Accessibility.Scene.Home.Compose", fallback: "Compose") /// Menu - public static let menu = L10n.tr("Localizable", "Accessibility.Scene.Home.Menu") + public static let menu = L10n.tr("Localizable", "Accessibility.Scene.Home.Menu", fallback: "Menu") public enum Drawer { /// Account DropDown - public static let accountDropdown = L10n.tr("Localizable", "Accessibility.Scene.Home.Drawer.AccountDropdown") + public static let accountDropdown = L10n.tr("Localizable", "Accessibility.Scene.Home.Drawer.AccountDropdown", fallback: "Account DropDown") } } public enum ManageAccounts { /// Add - public static let add = L10n.tr("Localizable", "Accessibility.Scene.ManageAccounts.Add") + public static let add = L10n.tr("Localizable", "Accessibility.Scene.ManageAccounts.Add", fallback: "Add") /// Current sign-in user: %@ public static func currentSignInUser(_ p1: Any) -> String { - return L10n.tr("Localizable", "Accessibility.Scene.ManageAccounts.CurrentSignInUser", String(describing: p1)) + return L10n.tr("Localizable", "Accessibility.Scene.ManageAccounts.CurrentSignInUser", String(describing: p1), fallback: "Current sign-in user: %@") } } public enum Search { /// History - public static let history = L10n.tr("Localizable", "Accessibility.Scene.Search.History") + public static let history = L10n.tr("Localizable", "Accessibility.Scene.Search.History", fallback: "History") /// Save - public static let save = L10n.tr("Localizable", "Accessibility.Scene.Search.Save") + public static let save = L10n.tr("Localizable", "Accessibility.Scene.Search.Save", fallback: "Save") } public enum Settings { public enum Display { /// Font Size - public static let fontSize = L10n.tr("Localizable", "Accessibility.Scene.Settings.Display.FontSize") + public static let fontSize = L10n.tr("Localizable", "Accessibility.Scene.Settings.Display.FontSize", fallback: "Font Size") } } public enum SignIn { /// Please enter Mastodon domain to sign-in - public static let pleaseEnterMastodonDomainToSignIn = L10n.tr("Localizable", "Accessibility.Scene.SignIn.PleaseEnterMastodonDomainToSignIn") + public static let pleaseEnterMastodonDomainToSignIn = L10n.tr("Localizable", "Accessibility.Scene.SignIn.PleaseEnterMastodonDomainToSignIn", fallback: "Please enter Mastodon domain to sign-in") /// Twitter client authentication key setting - public static let twitterClientAuthenticationKeySetting = L10n.tr("Localizable", "Accessibility.Scene.SignIn.TwitterClientAuthenticationKeySetting") + public static let twitterClientAuthenticationKeySetting = L10n.tr("Localizable", "Accessibility.Scene.SignIn.TwitterClientAuthenticationKeySetting", fallback: "Twitter client authentication key setting") } public enum Timeline { /// Load - public static let loadGap = L10n.tr("Localizable", "Accessibility.Scene.Timeline.LoadGap") + public static let loadGap = L10n.tr("Localizable", "Accessibility.Scene.Timeline.LoadGap", fallback: "Load") } public enum User { /// Location - public static let location = L10n.tr("Localizable", "Accessibility.Scene.User.Location") + public static let location = L10n.tr("Localizable", "Accessibility.Scene.User.Location", fallback: "Location") /// Website - public static let website = L10n.tr("Localizable", "Accessibility.Scene.User.Website") + public static let website = L10n.tr("Localizable", "Accessibility.Scene.User.Website", fallback: "Website") public enum Tab { /// Favourite - public static let favourite = L10n.tr("Localizable", "Accessibility.Scene.User.Tab.Favourite") + public static let favourite = L10n.tr("Localizable", "Accessibility.Scene.User.Tab.Favourite", fallback: "Favourite") /// Media - public static let media = L10n.tr("Localizable", "Accessibility.Scene.User.Tab.Media") + public static let media = L10n.tr("Localizable", "Accessibility.Scene.User.Tab.Media", fallback: "Media") /// Statuses - public static let status = L10n.tr("Localizable", "Accessibility.Scene.User.Tab.Status") + public static let status = L10n.tr("Localizable", "Accessibility.Scene.User.Tab.Status", fallback: "Statuses") } } } public enum VoiceOver { /// Double tap and hold to display menu - public static let doubleTapAndHoldToDisplayMenu = L10n.tr("Localizable", "Accessibility.VoiceOver.DoubleTapAndHoldToDisplayMenu") + public static let doubleTapAndHoldToDisplayMenu = L10n.tr("Localizable", "Accessibility.VoiceOver.DoubleTapAndHoldToDisplayMenu", fallback: "Double tap and hold to display menu") /// Double tap and hold to open the accounts panel - public static let doubleTapAndHoldToOpenTheAccountsPanel = L10n.tr("Localizable", "Accessibility.VoiceOver.DoubleTapAndHoldToOpenTheAccountsPanel") + public static let doubleTapAndHoldToOpenTheAccountsPanel = L10n.tr("Localizable", "Accessibility.VoiceOver.DoubleTapAndHoldToOpenTheAccountsPanel", fallback: "Double tap and hold to open the accounts panel") /// Double tap to open profile - public static let doubleTapToOpenProfile = L10n.tr("Localizable", "Accessibility.VoiceOver.DoubleTapToOpenProfile") + public static let doubleTapToOpenProfile = L10n.tr("Localizable", "Accessibility.VoiceOver.DoubleTapToOpenProfile", fallback: "Double tap to open profile") /// Selected - public static let selected = L10n.tr("Localizable", "Accessibility.VoiceOver.Selected") + public static let selected = L10n.tr("Localizable", "Accessibility.VoiceOver.Selected", fallback: "Selected") } } - public enum Common { public enum Alerts { public enum AccountSuspended { /// Twitter suspends accounts which violate the %@ public static func message(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Alerts.AccountSuspended.Message", String(describing: p1)) + return L10n.tr("Localizable", "Common.Alerts.AccountSuspended.Message", String(describing: p1), fallback: "Twitter suspends accounts which violate the %@") } /// Account Suspended - public static let title = L10n.tr("Localizable", "Common.Alerts.AccountSuspended.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.AccountSuspended.Title", fallback: "Account Suspended") /// Twitter Rules - public static let twitterRules = L10n.tr("Localizable", "Common.Alerts.AccountSuspended.TwitterRules") + public static let twitterRules = L10n.tr("Localizable", "Common.Alerts.AccountSuspended.TwitterRules", fallback: "Twitter Rules") } public enum AccountTemporarilyLocked { /// Open Twitter to unlock - public static let message = L10n.tr("Localizable", "Common.Alerts.AccountTemporarilyLocked.Message") + public static let message = L10n.tr("Localizable", "Common.Alerts.AccountTemporarilyLocked.Message", fallback: "Open Twitter to unlock") /// Account Temporarily Locked - public static let title = L10n.tr("Localizable", "Common.Alerts.AccountTemporarilyLocked.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.AccountTemporarilyLocked.Title", fallback: "Account Temporarily Locked") } public enum BlockUserConfirm { /// Do you want to block %@? public static func title(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Alerts.BlockUserConfirm.Title", String(describing: p1)) + return L10n.tr("Localizable", "Common.Alerts.BlockUserConfirm.Title", String(describing: p1), fallback: "Do you want to block %@?") } } public enum BlockUserSuccess { /// %@ has been blocked public static func title(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Alerts.BlockUserSuccess.Title", String(describing: p1)) + return L10n.tr("Localizable", "Common.Alerts.BlockUserSuccess.Title", String(describing: p1), fallback: "%@ has been blocked") } } public enum CancelFollowRequest { /// Cancel follow request for %@? public static func message(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Alerts.CancelFollowRequest.Message", String(describing: p1)) + return L10n.tr("Localizable", "Common.Alerts.CancelFollowRequest.Message", String(describing: p1), fallback: "Cancel follow request for %@?") } } public enum DeleteTootConfirm { /// Do you want to delete this toot? - public static let message = L10n.tr("Localizable", "Common.Alerts.DeleteTootConfirm.Message") + public static let message = L10n.tr("Localizable", "Common.Alerts.DeleteTootConfirm.Message", fallback: "Do you want to delete this toot?") /// Delete Toot - public static let title = L10n.tr("Localizable", "Common.Alerts.DeleteTootConfirm.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.DeleteTootConfirm.Title", fallback: "Delete Toot") } public enum DeleteTweetConfirm { /// Do you want to delete this tweet? - public static let message = L10n.tr("Localizable", "Common.Alerts.DeleteTweetConfirm.Message") + public static let message = L10n.tr("Localizable", "Common.Alerts.DeleteTweetConfirm.Message", fallback: "Do you want to delete this tweet?") /// Delete Tweet - public static let title = L10n.tr("Localizable", "Common.Alerts.DeleteTweetConfirm.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.DeleteTweetConfirm.Title", fallback: "Delete Tweet") } public enum FailedToAddListMember { /// Please try again - public static let message = L10n.tr("Localizable", "Common.Alerts.FailedToAddListMember.Message") + public static let message = L10n.tr("Localizable", "Common.Alerts.FailedToAddListMember.Message", fallback: "Please try again") /// Failed to Add List Member - public static let title = L10n.tr("Localizable", "Common.Alerts.FailedToAddListMember.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.FailedToAddListMember.Title", fallback: "Failed to Add List Member") } public enum FailedToBlockUser { /// Please try again - public static let message = L10n.tr("Localizable", "Common.Alerts.FailedToBlockUser.Message") + public static let message = L10n.tr("Localizable", "Common.Alerts.FailedToBlockUser.Message", fallback: "Please try again") /// Failed to block %@ public static func title(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Alerts.FailedToBlockUser.Title", String(describing: p1)) + return L10n.tr("Localizable", "Common.Alerts.FailedToBlockUser.Title", String(describing: p1), fallback: "Failed to block %@") } } public enum FailedToDeleteList { /// Please try again - public static let message = L10n.tr("Localizable", "Common.Alerts.FailedToDeleteList.Message") + public static let message = L10n.tr("Localizable", "Common.Alerts.FailedToDeleteList.Message", fallback: "Please try again") /// Failed to Delete List - public static let title = L10n.tr("Localizable", "Common.Alerts.FailedToDeleteList.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.FailedToDeleteList.Title", fallback: "Failed to Delete List") } public enum FailedToDeleteToot { /// Please try again - public static let message = L10n.tr("Localizable", "Common.Alerts.FailedToDeleteToot.Message") + public static let message = L10n.tr("Localizable", "Common.Alerts.FailedToDeleteToot.Message", fallback: "Please try again") /// Failed to Delete Toot - public static let title = L10n.tr("Localizable", "Common.Alerts.FailedToDeleteToot.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.FailedToDeleteToot.Title", fallback: "Failed to Delete Toot") } public enum FailedToDeleteTweet { /// Please try again - public static let message = L10n.tr("Localizable", "Common.Alerts.FailedToDeleteTweet.Message") + public static let message = L10n.tr("Localizable", "Common.Alerts.FailedToDeleteTweet.Message", fallback: "Please try again") /// Failed to Delete Tweet - public static let title = L10n.tr("Localizable", "Common.Alerts.FailedToDeleteTweet.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.FailedToDeleteTweet.Title", fallback: "Failed to Delete Tweet") } public enum FailedToFollowing { /// Please try again - public static let message = L10n.tr("Localizable", "Common.Alerts.FailedToFollowing.Message") + public static let message = L10n.tr("Localizable", "Common.Alerts.FailedToFollowing.Message", fallback: "Please try again") /// Failed to Following - public static let title = L10n.tr("Localizable", "Common.Alerts.FailedToFollowing.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.FailedToFollowing.Title", fallback: "Failed to Following") } public enum FailedToLoad { /// Please try again - public static let message = L10n.tr("Localizable", "Common.Alerts.FailedToLoad.Message") + public static let message = L10n.tr("Localizable", "Common.Alerts.FailedToLoad.Message", fallback: "Please try again") /// Failed to Load - public static let title = L10n.tr("Localizable", "Common.Alerts.FailedToLoad.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.FailedToLoad.Title", fallback: "Failed to Load") } public enum FailedToLoginMastodonServer { /// Server URL is incorrect. - public static let message = L10n.tr("Localizable", "Common.Alerts.FailedToLoginMastodonServer.Message") + public static let message = L10n.tr("Localizable", "Common.Alerts.FailedToLoginMastodonServer.Message", fallback: "Server URL is incorrect.") /// Failed to Login - public static let title = L10n.tr("Localizable", "Common.Alerts.FailedToLoginMastodonServer.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.FailedToLoginMastodonServer.Title", fallback: "Failed to Login") } public enum FailedToLoginTimeout { /// Connection timeout. - public static let message = L10n.tr("Localizable", "Common.Alerts.FailedToLoginTimeout.Message") + public static let message = L10n.tr("Localizable", "Common.Alerts.FailedToLoginTimeout.Message", fallback: "Connection timeout.") /// Failed to Login - public static let title = L10n.tr("Localizable", "Common.Alerts.FailedToLoginTimeout.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.FailedToLoginTimeout.Title", fallback: "Failed to Login") } public enum FailedToMuteUser { /// Please try again - public static let message = L10n.tr("Localizable", "Common.Alerts.FailedToMuteUser.Message") + public static let message = L10n.tr("Localizable", "Common.Alerts.FailedToMuteUser.Message", fallback: "Please try again") /// Failed to mute %@ public static func title(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Alerts.FailedToMuteUser.Title", String(describing: p1)) + return L10n.tr("Localizable", "Common.Alerts.FailedToMuteUser.Title", String(describing: p1), fallback: "Failed to mute %@") } } public enum FailedToRemoveListMember { /// Please try again - public static let message = L10n.tr("Localizable", "Common.Alerts.FailedToRemoveListMember.Message") + public static let message = L10n.tr("Localizable", "Common.Alerts.FailedToRemoveListMember.Message", fallback: "Please try again") /// Failed to Remove List Member - public static let title = L10n.tr("Localizable", "Common.Alerts.FailedToRemoveListMember.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.FailedToRemoveListMember.Title", fallback: "Failed to Remove List Member") } public enum FailedToReportAndBlockUser { /// Please try again - public static let message = L10n.tr("Localizable", "Common.Alerts.FailedToReportAndBlockUser.Message") + public static let message = L10n.tr("Localizable", "Common.Alerts.FailedToReportAndBlockUser.Message", fallback: "Please try again") /// Failed to report and block %@ public static func title(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Alerts.FailedToReportAndBlockUser.Title", String(describing: p1)) + return L10n.tr("Localizable", "Common.Alerts.FailedToReportAndBlockUser.Title", String(describing: p1), fallback: "Failed to report and block %@") } } public enum FailedToReportUser { /// Please try again - public static let message = L10n.tr("Localizable", "Common.Alerts.FailedToReportUser.Message") + public static let message = L10n.tr("Localizable", "Common.Alerts.FailedToReportUser.Message", fallback: "Please try again") /// Failed to report %@ public static func title(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Alerts.FailedToReportUser.Title", String(describing: p1)) + return L10n.tr("Localizable", "Common.Alerts.FailedToReportUser.Title", String(describing: p1), fallback: "Failed to report %@") } } public enum FailedToSendMessage { /// Failed to send message - public static let message = L10n.tr("Localizable", "Common.Alerts.FailedToSendMessage.Message") + public static let message = L10n.tr("Localizable", "Common.Alerts.FailedToSendMessage.Message", fallback: "Failed to send message") /// Sending message - public static let title = L10n.tr("Localizable", "Common.Alerts.FailedToSendMessage.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.FailedToSendMessage.Title", fallback: "Sending message") } public enum FailedToUnblockUser { /// Please try again - public static let message = L10n.tr("Localizable", "Common.Alerts.FailedToUnblockUser.Message") + public static let message = L10n.tr("Localizable", "Common.Alerts.FailedToUnblockUser.Message", fallback: "Please try again") /// Failed to unblock %@ public static func title(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Alerts.FailedToUnblockUser.Title", String(describing: p1)) + return L10n.tr("Localizable", "Common.Alerts.FailedToUnblockUser.Title", String(describing: p1), fallback: "Failed to unblock %@") } } public enum FailedToUnfollowing { /// Please try again - public static let message = L10n.tr("Localizable", "Common.Alerts.FailedToUnfollowing.Message") + public static let message = L10n.tr("Localizable", "Common.Alerts.FailedToUnfollowing.Message", fallback: "Please try again") /// Failed to Unfollowing - public static let title = L10n.tr("Localizable", "Common.Alerts.FailedToUnfollowing.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.FailedToUnfollowing.Title", fallback: "Failed to Unfollowing") } public enum FailedToUnmuteUser { /// Please try again - public static let message = L10n.tr("Localizable", "Common.Alerts.FailedToUnmuteUser.Message") + public static let message = L10n.tr("Localizable", "Common.Alerts.FailedToUnmuteUser.Message", fallback: "Please try again") /// Failed to unmute %@ public static func title(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Alerts.FailedToUnmuteUser.Title", String(describing: p1)) + return L10n.tr("Localizable", "Common.Alerts.FailedToUnmuteUser.Title", String(describing: p1), fallback: "Failed to unmute %@") } } public enum FollowingRequestSent { /// Following Request Sent - public static let title = L10n.tr("Localizable", "Common.Alerts.FollowingRequestSent.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.FollowingRequestSent.Title", fallback: "Following Request Sent") } public enum FollowingSuccess { /// Following Succeeded - public static let title = L10n.tr("Localizable", "Common.Alerts.FollowingSuccess.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.FollowingSuccess.Title", fallback: "Following Succeeded") } public enum ListDeleted { /// Please try again - public static let message = L10n.tr("Localizable", "Common.Alerts.ListDeleted.Message") + public static let message = L10n.tr("Localizable", "Common.Alerts.ListDeleted.Message", fallback: "Please try again") /// List Deleted - public static let title = L10n.tr("Localizable", "Common.Alerts.ListDeleted.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.ListDeleted.Title", fallback: "List Deleted") } public enum ListMemberRemoved { /// List Member Removed - public static let title = L10n.tr("Localizable", "Common.Alerts.ListMemberRemoved.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.ListMemberRemoved.Title", fallback: "List Member Removed") } public enum MediaSaveFail { /// Please try again - public static let message = L10n.tr("Localizable", "Common.Alerts.MediaSaveFail.Message") + public static let message = L10n.tr("Localizable", "Common.Alerts.MediaSaveFail.Message", fallback: "Please try again") /// Failed to save media - public static let title = L10n.tr("Localizable", "Common.Alerts.MediaSaveFail.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.MediaSaveFail.Title", fallback: "Failed to save media") } public enum MediaSaved { /// Media saved - public static let title = L10n.tr("Localizable", "Common.Alerts.MediaSaved.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.MediaSaved.Title", fallback: "Media saved") } public enum MediaSaving { /// Saving media - public static let title = L10n.tr("Localizable", "Common.Alerts.MediaSaving.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.MediaSaving.Title", fallback: "Saving media") } public enum MediaSharing { /// Media will be shared after download is completed - public static let title = L10n.tr("Localizable", "Common.Alerts.MediaSharing.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.MediaSharing.Title", fallback: "Media will be shared after download is completed") } public enum MuteUserConfirm { /// Do you want to mute %@? public static func title(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Alerts.MuteUserConfirm.Title", String(describing: p1)) + return L10n.tr("Localizable", "Common.Alerts.MuteUserConfirm.Title", String(describing: p1), fallback: "Do you want to mute %@?") } } public enum MuteUserSuccess { /// %@ has been muted public static func title(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Alerts.MuteUserSuccess.Title", String(describing: p1)) + return L10n.tr("Localizable", "Common.Alerts.MuteUserSuccess.Title", String(describing: p1), fallback: "%@ has been muted") } } public enum NoTweetsFound { /// No Tweets Found - public static let title = L10n.tr("Localizable", "Common.Alerts.NoTweetsFound.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.NoTweetsFound.Title", fallback: "No Tweets Found") } public enum PermissionDeniedFriendshipBlocked { /// You have been blocked from following this account at the request of the user - public static let message = L10n.tr("Localizable", "Common.Alerts.PermissionDeniedFriendshipBlocked.Message") + public static let message = L10n.tr("Localizable", "Common.Alerts.PermissionDeniedFriendshipBlocked.Message", fallback: "You have been blocked from following this account at the request of the user") /// Permission Denied - public static let title = L10n.tr("Localizable", "Common.Alerts.PermissionDeniedFriendshipBlocked.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.PermissionDeniedFriendshipBlocked.Title", fallback: "Permission Denied") } public enum PermissionDeniedNotAuthorized { /// Sorry, you are not authorized - public static let message = L10n.tr("Localizable", "Common.Alerts.PermissionDeniedNotAuthorized.Message") + public static let message = L10n.tr("Localizable", "Common.Alerts.PermissionDeniedNotAuthorized.Message", fallback: "Sorry, you are not authorized") /// Permission Denied - public static let title = L10n.tr("Localizable", "Common.Alerts.PermissionDeniedNotAuthorized.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.PermissionDeniedNotAuthorized.Title", fallback: "Permission Denied") } public enum PhotoCopied { /// Photo Copied - public static let title = L10n.tr("Localizable", "Common.Alerts.PhotoCopied.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.PhotoCopied.Title", fallback: "Photo Copied") } public enum PhotoCopyFail { /// Please try again - public static let message = L10n.tr("Localizable", "Common.Alerts.PhotoCopyFail.Message") + public static let message = L10n.tr("Localizable", "Common.Alerts.PhotoCopyFail.Message", fallback: "Please try again") /// Failed to Copy Photo - public static let title = L10n.tr("Localizable", "Common.Alerts.PhotoCopyFail.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.PhotoCopyFail.Title", fallback: "Failed to Copy Photo") } public enum PhotoSaveFail { /// Please try again - public static let message = L10n.tr("Localizable", "Common.Alerts.PhotoSaveFail.Message") + public static let message = L10n.tr("Localizable", "Common.Alerts.PhotoSaveFail.Message", fallback: "Please try again") /// Failed to Save Photo - public static let title = L10n.tr("Localizable", "Common.Alerts.PhotoSaveFail.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.PhotoSaveFail.Title", fallback: "Failed to Save Photo") } public enum PhotoSaved { /// Photo Saved - public static let title = L10n.tr("Localizable", "Common.Alerts.PhotoSaved.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.PhotoSaved.Title", fallback: "Photo Saved") } public enum PostFailInvalidPoll { /// Poll has empty field. Please fulfill the field then try again - public static let message = L10n.tr("Localizable", "Common.Alerts.PostFailInvalidPoll.Message") + public static let message = L10n.tr("Localizable", "Common.Alerts.PostFailInvalidPoll.Message", fallback: "Poll has empty field. Please fulfill the field then try again") /// Failed to Publish - public static let title = L10n.tr("Localizable", "Common.Alerts.PostFailInvalidPoll.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.PostFailInvalidPoll.Title", fallback: "Failed to Publish") } public enum RateLimitExceeded { /// Reached Twitter API usage limit - public static let message = L10n.tr("Localizable", "Common.Alerts.RateLimitExceeded.Message") + public static let message = L10n.tr("Localizable", "Common.Alerts.RateLimitExceeded.Message", fallback: "Reached Twitter API usage limit") /// Rate Limit Exceeded - public static let title = L10n.tr("Localizable", "Common.Alerts.RateLimitExceeded.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.RateLimitExceeded.Title", fallback: "Rate Limit Exceeded") } public enum ReportAndBlockUserSuccess { /// %@ has been reported for spam and blocked public static func title(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Alerts.ReportAndBlockUserSuccess.Title", String(describing: p1)) + return L10n.tr("Localizable", "Common.Alerts.ReportAndBlockUserSuccess.Title", String(describing: p1), fallback: "%@ has been reported for spam and blocked") } } public enum ReportUserSuccess { /// %@ has been reported for spam public static func title(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Alerts.ReportUserSuccess.Title", String(describing: p1)) + return L10n.tr("Localizable", "Common.Alerts.ReportUserSuccess.Title", String(describing: p1), fallback: "%@ has been reported for spam") } } + public enum RequestThrottle { + /// Operation too frequent. Please try again later + public static let message = L10n.tr("Localizable", "Common.Alerts.RequestThrottle.Message", fallback: "Operation too frequent. Please try again later") + /// Request Throttle + public static let title = L10n.tr("Localizable", "Common.Alerts.RequestThrottle.Title", fallback: "Request Throttle") + } public enum SignOutUserConfirm { /// Do you want to sign out? - public static let message = L10n.tr("Localizable", "Common.Alerts.SignOutUserConfirm.Message") + public static let message = L10n.tr("Localizable", "Common.Alerts.SignOutUserConfirm.Message", fallback: "Do you want to sign out?") /// Sign out - public static let title = L10n.tr("Localizable", "Common.Alerts.SignOutUserConfirm.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.SignOutUserConfirm.Title", fallback: "Sign out") } public enum TooManyRequests { /// Too Many Requests - public static let title = L10n.tr("Localizable", "Common.Alerts.TooManyRequests.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.TooManyRequests.Title", fallback: "Too Many Requests") } public enum TootDeleted { /// Toot Deleted - public static let title = L10n.tr("Localizable", "Common.Alerts.TootDeleted.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.TootDeleted.Title", fallback: "Toot Deleted") } public enum TootFail { /// Your toot has been saved to Drafts. - public static let draftSavedMessage = L10n.tr("Localizable", "Common.Alerts.TootFail.DraftSavedMessage") + public static let draftSavedMessage = L10n.tr("Localizable", "Common.Alerts.TootFail.DraftSavedMessage", fallback: "Your toot has been saved to Drafts.") /// Please try again - public static let message = L10n.tr("Localizable", "Common.Alerts.TootFail.Message") + public static let message = L10n.tr("Localizable", "Common.Alerts.TootFail.Message", fallback: "Please try again") /// Failed to Toot - public static let title = L10n.tr("Localizable", "Common.Alerts.TootFail.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.TootFail.Title", fallback: "Failed to Toot") } public enum TootPosted { /// Toot Posted - public static let title = L10n.tr("Localizable", "Common.Alerts.TootPosted.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.TootPosted.Title", fallback: "Toot Posted") } public enum TootSending { /// Sending toot - public static let title = L10n.tr("Localizable", "Common.Alerts.TootSending.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.TootSending.Title", fallback: "Sending toot") } public enum TweetDeleted { /// Tweet Deleted - public static let title = L10n.tr("Localizable", "Common.Alerts.TweetDeleted.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.TweetDeleted.Title", fallback: "Tweet Deleted") } public enum TweetFail { /// Your tweet has been saved to Drafts. - public static let draftSavedMessage = L10n.tr("Localizable", "Common.Alerts.TweetFail.DraftSavedMessage") + public static let draftSavedMessage = L10n.tr("Localizable", "Common.Alerts.TweetFail.DraftSavedMessage", fallback: "Your tweet has been saved to Drafts.") /// Please try again - public static let message = L10n.tr("Localizable", "Common.Alerts.TweetFail.Message") + public static let message = L10n.tr("Localizable", "Common.Alerts.TweetFail.Message", fallback: "Please try again") /// Failed to Tweet - public static let title = L10n.tr("Localizable", "Common.Alerts.TweetFail.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.TweetFail.Title", fallback: "Failed to Tweet") } public enum TweetPosted { /// Tweet Posted - public static let title = L10n.tr("Localizable", "Common.Alerts.TweetPosted.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.TweetPosted.Title", fallback: "Tweet Posted") } public enum TweetSending { /// Sending tweet - public static let title = L10n.tr("Localizable", "Common.Alerts.TweetSending.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.TweetSending.Title", fallback: "Sending tweet") } public enum TweetSent { /// Tweet Sent - public static let title = L10n.tr("Localizable", "Common.Alerts.TweetSent.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.TweetSent.Title", fallback: "Tweet Sent") } public enum UnblockUserConfirm { /// Do you want to unblock %@? public static func title(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Alerts.UnblockUserConfirm.Title", String(describing: p1)) + return L10n.tr("Localizable", "Common.Alerts.UnblockUserConfirm.Title", String(describing: p1), fallback: "Do you want to unblock %@?") } } public enum UnblockUserSuccess { /// %@ has been unblocked public static func title(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Alerts.UnblockUserSuccess.Title", String(describing: p1)) + return L10n.tr("Localizable", "Common.Alerts.UnblockUserSuccess.Title", String(describing: p1), fallback: "%@ has been unblocked") } } public enum UnfollowUser { /// Unfollow user %@? public static func message(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Alerts.UnfollowUser.Message", String(describing: p1)) + return L10n.tr("Localizable", "Common.Alerts.UnfollowUser.Message", String(describing: p1), fallback: "Unfollow user %@?") } } public enum UnfollowingSuccess { /// Unfollowing Succeeded - public static let title = L10n.tr("Localizable", "Common.Alerts.UnfollowingSuccess.Title") + public static let title = L10n.tr("Localizable", "Common.Alerts.UnfollowingSuccess.Title", fallback: "Unfollowing Succeeded") } public enum UnmuteUserConfirm { /// Do you want to unmute %@? public static func title(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Alerts.UnmuteUserConfirm.Title", String(describing: p1)) + return L10n.tr("Localizable", "Common.Alerts.UnmuteUserConfirm.Title", String(describing: p1), fallback: "Do you want to unmute %@?") } } public enum UnmuteUserSuccess { /// %@ has been unmuted public static func title(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Alerts.UnmuteUserSuccess.Title", String(describing: p1)) + return L10n.tr("Localizable", "Common.Alerts.UnmuteUserSuccess.Title", String(describing: p1), fallback: "%@ has been unmuted") } } } public enum Controls { public enum Actions { /// Add - public static let add = L10n.tr("Localizable", "Common.Controls.Actions.Add") + public static let add = L10n.tr("Localizable", "Common.Controls.Actions.Add", fallback: "Add") /// Browse - public static let browse = L10n.tr("Localizable", "Common.Controls.Actions.Browse") + public static let browse = L10n.tr("Localizable", "Common.Controls.Actions.Browse", fallback: "Browse") /// Cancel - public static let cancel = L10n.tr("Localizable", "Common.Controls.Actions.Cancel") + public static let cancel = L10n.tr("Localizable", "Common.Controls.Actions.Cancel", fallback: "Cancel") /// Clear - public static let clear = L10n.tr("Localizable", "Common.Controls.Actions.Clear") + public static let clear = L10n.tr("Localizable", "Common.Controls.Actions.Clear", fallback: "Clear") /// Confirm - public static let confirm = L10n.tr("Localizable", "Common.Controls.Actions.Confirm") + public static let confirm = L10n.tr("Localizable", "Common.Controls.Actions.Confirm", fallback: "Confirm") /// Copy - public static let copy = L10n.tr("Localizable", "Common.Controls.Actions.Copy") + public static let copy = L10n.tr("Localizable", "Common.Controls.Actions.Copy", fallback: "Copy") /// Delete - public static let delete = L10n.tr("Localizable", "Common.Controls.Actions.Delete") + public static let delete = L10n.tr("Localizable", "Common.Controls.Actions.Delete", fallback: "Delete") /// Done - public static let done = L10n.tr("Localizable", "Common.Controls.Actions.Done") + public static let done = L10n.tr("Localizable", "Common.Controls.Actions.Done", fallback: "Done") /// Edit - public static let edit = L10n.tr("Localizable", "Common.Controls.Actions.Edit") + public static let edit = L10n.tr("Localizable", "Common.Controls.Actions.Edit", fallback: "Edit") /// OK - public static let ok = L10n.tr("Localizable", "Common.Controls.Actions.Ok") + public static let ok = L10n.tr("Localizable", "Common.Controls.Actions.Ok", fallback: "OK") /// Open in Safari - public static let openInSafari = L10n.tr("Localizable", "Common.Controls.Actions.OpenInSafari") + public static let openInSafari = L10n.tr("Localizable", "Common.Controls.Actions.OpenInSafari", fallback: "Open in Safari") /// Preview - public static let preview = L10n.tr("Localizable", "Common.Controls.Actions.Preview") + public static let preview = L10n.tr("Localizable", "Common.Controls.Actions.Preview", fallback: "Preview") /// Remove - public static let remove = L10n.tr("Localizable", "Common.Controls.Actions.Remove") + public static let remove = L10n.tr("Localizable", "Common.Controls.Actions.Remove", fallback: "Remove") /// Save - public static let save = L10n.tr("Localizable", "Common.Controls.Actions.Save") + public static let save = L10n.tr("Localizable", "Common.Controls.Actions.Save", fallback: "Save") /// Save photo - public static let savePhoto = L10n.tr("Localizable", "Common.Controls.Actions.SavePhoto") + public static let savePhoto = L10n.tr("Localizable", "Common.Controls.Actions.SavePhoto", fallback: "Save photo") /// Share - public static let share = L10n.tr("Localizable", "Common.Controls.Actions.Share") + public static let share = L10n.tr("Localizable", "Common.Controls.Actions.Share", fallback: "Share") /// Share link - public static let shareLink = L10n.tr("Localizable", "Common.Controls.Actions.ShareLink") + public static let shareLink = L10n.tr("Localizable", "Common.Controls.Actions.ShareLink", fallback: "Share link") /// Share media - public static let shareMedia = L10n.tr("Localizable", "Common.Controls.Actions.ShareMedia") + public static let shareMedia = L10n.tr("Localizable", "Common.Controls.Actions.ShareMedia", fallback: "Share media") /// Sign in - public static let signIn = L10n.tr("Localizable", "Common.Controls.Actions.SignIn") + public static let signIn = L10n.tr("Localizable", "Common.Controls.Actions.SignIn", fallback: "Sign in") /// Sign out - public static let signOut = L10n.tr("Localizable", "Common.Controls.Actions.SignOut") + public static let signOut = L10n.tr("Localizable", "Common.Controls.Actions.SignOut", fallback: "Sign out") /// Take photo - public static let takePhoto = L10n.tr("Localizable", "Common.Controls.Actions.TakePhoto") + public static let takePhoto = L10n.tr("Localizable", "Common.Controls.Actions.TakePhoto", fallback: "Take photo") /// Yes - public static let yes = L10n.tr("Localizable", "Common.Controls.Actions.Yes") + public static let yes = L10n.tr("Localizable", "Common.Controls.Actions.Yes", fallback: "Yes") public enum ShareMediaMenu { /// Link - public static let link = L10n.tr("Localizable", "Common.Controls.Actions.ShareMediaMenu.Link") + public static let link = L10n.tr("Localizable", "Common.Controls.Actions.ShareMediaMenu.Link", fallback: "Link") /// Media - public static let media = L10n.tr("Localizable", "Common.Controls.Actions.ShareMediaMenu.Media") + public static let media = L10n.tr("Localizable", "Common.Controls.Actions.ShareMediaMenu.Media", fallback: "Media") } } + public enum EmptyState { + /// No results + public static let noResults = L10n.tr("Localizable", "Common.Controls.EmptyState.NoResults", fallback: "No results") + /// Unable to access + public static let unableToAccess = L10n.tr("Localizable", "Common.Controls.EmptyState.UnableToAccess", fallback: "Unable to access") + } public enum Friendship { /// Block %@ public static func blockUser(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Controls.Friendship.BlockUser", String(describing: p1)) + return L10n.tr("Localizable", "Common.Controls.Friendship.BlockUser", String(describing: p1), fallback: "Block %@") } /// Do you want to report and block %@ public static func doYouWantToReportAndBlockUser(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Controls.Friendship.DoYouWantToReportAnd BlockUser", String(describing: p1)) + return L10n.tr("Localizable", "Common.Controls.Friendship.DoYouWantToReportAndBlockUser", String(describing: p1), fallback: "Do you want to report and block %@") } /// Do you want to report %@ public static func doYouWantToReportUser(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Controls.Friendship.DoYouWantToReportUser", String(describing: p1)) + return L10n.tr("Localizable", "Common.Controls.Friendship.DoYouWantToReportUser", String(describing: p1), fallback: "Do you want to report %@") } /// follower - public static let follower = L10n.tr("Localizable", "Common.Controls.Friendship.Follower") + public static let follower = L10n.tr("Localizable", "Common.Controls.Friendship.Follower", fallback: "follower") /// followers - public static let followers = L10n.tr("Localizable", "Common.Controls.Friendship.Followers") + public static let followers = L10n.tr("Localizable", "Common.Controls.Friendship.Followers", fallback: "followers") /// Follows you - public static let followsYou = L10n.tr("Localizable", "Common.Controls.Friendship.FollowsYou") + public static let followsYou = L10n.tr("Localizable", "Common.Controls.Friendship.FollowsYou", fallback: "Follows you") /// Mute %@ public static func muteUser(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Controls.Friendship.MuteUser", String(describing: p1)) + return L10n.tr("Localizable", "Common.Controls.Friendship.MuteUser", String(describing: p1), fallback: "Mute %@") } /// %@ is following you public static func userIsFollowingYou(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Controls.Friendship.UserIsFollowingYou", String(describing: p1)) + return L10n.tr("Localizable", "Common.Controls.Friendship.UserIsFollowingYou", String(describing: p1), fallback: "%@ is following you") } /// %@ is not following you public static func userIsNotFollowingYou(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Controls.Friendship.UserIsNotFollowingYou", String(describing: p1)) + return L10n.tr("Localizable", "Common.Controls.Friendship.UserIsNotFollowingYou", String(describing: p1), fallback: "%@ is not following you") } public enum Actions { /// Block - public static let block = L10n.tr("Localizable", "Common.Controls.Friendship.Actions.Block") + public static let block = L10n.tr("Localizable", "Common.Controls.Friendship.Actions.Block", fallback: "Block") /// Blocked - public static let blocked = L10n.tr("Localizable", "Common.Controls.Friendship.Actions.Blocked") + public static let blocked = L10n.tr("Localizable", "Common.Controls.Friendship.Actions.Blocked", fallback: "Blocked") /// Follow - public static let follow = L10n.tr("Localizable", "Common.Controls.Friendship.Actions.Follow") + public static let follow = L10n.tr("Localizable", "Common.Controls.Friendship.Actions.Follow", fallback: "Follow") /// Following - public static let following = L10n.tr("Localizable", "Common.Controls.Friendship.Actions.Following") + public static let following = L10n.tr("Localizable", "Common.Controls.Friendship.Actions.Following", fallback: "Following") /// Mute - public static let mute = L10n.tr("Localizable", "Common.Controls.Friendship.Actions.Mute") + public static let mute = L10n.tr("Localizable", "Common.Controls.Friendship.Actions.Mute", fallback: "Mute") /// Pending - public static let pending = L10n.tr("Localizable", "Common.Controls.Friendship.Actions.Pending") + public static let pending = L10n.tr("Localizable", "Common.Controls.Friendship.Actions.Pending", fallback: "Pending") /// Report - public static let report = L10n.tr("Localizable", "Common.Controls.Friendship.Actions.Report") + public static let report = L10n.tr("Localizable", "Common.Controls.Friendship.Actions.Report", fallback: "Report") /// Report and Block - public static let reportAndBlock = L10n.tr("Localizable", "Common.Controls.Friendship.Actions.ReportAndBlock") + public static let reportAndBlock = L10n.tr("Localizable", "Common.Controls.Friendship.Actions.ReportAndBlock", fallback: "Report and Block") /// Request - public static let request = L10n.tr("Localizable", "Common.Controls.Friendship.Actions.Request") + public static let request = L10n.tr("Localizable", "Common.Controls.Friendship.Actions.Request", fallback: "Request") /// Unblock - public static let unblock = L10n.tr("Localizable", "Common.Controls.Friendship.Actions.Unblock") + public static let unblock = L10n.tr("Localizable", "Common.Controls.Friendship.Actions.Unblock", fallback: "Unblock") /// Unfollow - public static let unfollow = L10n.tr("Localizable", "Common.Controls.Friendship.Actions.Unfollow") + public static let unfollow = L10n.tr("Localizable", "Common.Controls.Friendship.Actions.Unfollow", fallback: "Unfollow") /// Unmute - public static let unmute = L10n.tr("Localizable", "Common.Controls.Friendship.Actions.Unmute") + public static let unmute = L10n.tr("Localizable", "Common.Controls.Friendship.Actions.Unmute", fallback: "Unmute") } } public enum Ios { /// Photo Library - public static let photoLibrary = L10n.tr("Localizable", "Common.Controls.Ios.PhotoLibrary") + public static let photoLibrary = L10n.tr("Localizable", "Common.Controls.Ios.PhotoLibrary", fallback: "Photo Library") } public enum List { /// No results - public static let noResults = L10n.tr("Localizable", "Common.Controls.List.NoResults") + public static let noResults = L10n.tr("Localizable", "Common.Controls.List.NoResults", fallback: "No results") } public enum ProfileDashboard { /// Followers - public static let followers = L10n.tr("Localizable", "Common.Controls.ProfileDashboard.Followers") + public static let followers = L10n.tr("Localizable", "Common.Controls.ProfileDashboard.Followers", fallback: "Followers") /// Following - public static let following = L10n.tr("Localizable", "Common.Controls.ProfileDashboard.Following") + public static let following = L10n.tr("Localizable", "Common.Controls.ProfileDashboard.Following", fallback: "Following") /// Listed - public static let listed = L10n.tr("Localizable", "Common.Controls.ProfileDashboard.Listed") + public static let listed = L10n.tr("Localizable", "Common.Controls.ProfileDashboard.Listed", fallback: "Listed") } public enum Status { /// Media - public static let media = L10n.tr("Localizable", "Common.Controls.Status.Media") + public static let media = L10n.tr("Localizable", "Common.Controls.Status.Media", fallback: "Media") /// %@ boosted public static func userBoosted(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Controls.Status.UserBoosted", String(describing: p1)) + return L10n.tr("Localizable", "Common.Controls.Status.UserBoosted", String(describing: p1), fallback: "%@ boosted") } /// %@ retweeted public static func userRetweeted(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Controls.Status.UserRetweeted", String(describing: p1)) + return L10n.tr("Localizable", "Common.Controls.Status.UserRetweeted", String(describing: p1), fallback: "%@ retweeted") } /// You boosted - public static let youBoosted = L10n.tr("Localizable", "Common.Controls.Status.YouBoosted") + public static let youBoosted = L10n.tr("Localizable", "Common.Controls.Status.YouBoosted", fallback: "You boosted") /// You retweeted - public static let youRetweeted = L10n.tr("Localizable", "Common.Controls.Status.YouRetweeted") + public static let youRetweeted = L10n.tr("Localizable", "Common.Controls.Status.YouRetweeted", fallback: "You retweeted") public enum Actions { /// Bookmark - public static let bookmark = L10n.tr("Localizable", "Common.Controls.Status.Actions.Bookmark") + public static let bookmark = L10n.tr("Localizable", "Common.Controls.Status.Actions.Bookmark", fallback: "Bookmark") /// Boost - public static let boost = L10n.tr("Localizable", "Common.Controls.Status.Actions.Boost") + public static let boost = L10n.tr("Localizable", "Common.Controls.Status.Actions.Boost", fallback: "Boost") /// Copy link - public static let copyLink = L10n.tr("Localizable", "Common.Controls.Status.Actions.CopyLink") + public static let copyLink = L10n.tr("Localizable", "Common.Controls.Status.Actions.CopyLink", fallback: "Copy link") /// Copy text - public static let copyText = L10n.tr("Localizable", "Common.Controls.Status.Actions.CopyText") + public static let copyText = L10n.tr("Localizable", "Common.Controls.Status.Actions.CopyText", fallback: "Copy text") /// Delete tweet - public static let deleteTweet = L10n.tr("Localizable", "Common.Controls.Status.Actions.DeleteTweet") + public static let deleteTweet = L10n.tr("Localizable", "Common.Controls.Status.Actions.DeleteTweet", fallback: "Delete tweet") + /// Like + public static let like = L10n.tr("Localizable", "Common.Controls.Status.Actions.Like", fallback: "Like") /// Pin on Profile - public static let pinOnProfile = L10n.tr("Localizable", "Common.Controls.Status.Actions.PinOnProfile") + public static let pinOnProfile = L10n.tr("Localizable", "Common.Controls.Status.Actions.PinOnProfile", fallback: "Pin on Profile") /// Quote - public static let quote = L10n.tr("Localizable", "Common.Controls.Status.Actions.Quote") + public static let quote = L10n.tr("Localizable", "Common.Controls.Status.Actions.Quote", fallback: "Quote") + /// Reply + public static let reply = L10n.tr("Localizable", "Common.Controls.Status.Actions.Reply", fallback: "Reply") + /// Repost + public static let repost = L10n.tr("Localizable", "Common.Controls.Status.Actions.Repost", fallback: "Repost") /// Retweet - public static let retweet = L10n.tr("Localizable", "Common.Controls.Status.Actions.Retweet") + public static let retweet = L10n.tr("Localizable", "Common.Controls.Status.Actions.Retweet", fallback: "Retweet") /// Save media - public static let saveMedia = L10n.tr("Localizable", "Common.Controls.Status.Actions.SaveMedia") + public static let saveMedia = L10n.tr("Localizable", "Common.Controls.Status.Actions.SaveMedia", fallback: "Save media") /// Share - public static let share = L10n.tr("Localizable", "Common.Controls.Status.Actions.Share") + public static let share = L10n.tr("Localizable", "Common.Controls.Status.Actions.Share", fallback: "Share") /// Share content - public static let shareContent = L10n.tr("Localizable", "Common.Controls.Status.Actions.ShareContent") + public static let shareContent = L10n.tr("Localizable", "Common.Controls.Status.Actions.ShareContent", fallback: "Share content") /// Share link - public static let shareLink = L10n.tr("Localizable", "Common.Controls.Status.Actions.ShareLink") + public static let shareLink = L10n.tr("Localizable", "Common.Controls.Status.Actions.ShareLink", fallback: "Share link") /// Translate - public static let translate = L10n.tr("Localizable", "Common.Controls.Status.Actions.Translate") + public static let translate = L10n.tr("Localizable", "Common.Controls.Status.Actions.Translate", fallback: "Translate") + /// Undo Boost + public static let undoBoost = L10n.tr("Localizable", "Common.Controls.Status.Actions.UndoBoost", fallback: "Undo Boost") + /// Undo Repost + public static let undoRepost = L10n.tr("Localizable", "Common.Controls.Status.Actions.UndoRepost", fallback: "Undo Repost") + /// Undo Retweet + public static let undoRetweet = L10n.tr("Localizable", "Common.Controls.Status.Actions.UndoRetweet", fallback: "Undo Retweet") /// Unpin from Profile - public static let unpinFromProfile = L10n.tr("Localizable", "Common.Controls.Status.Actions.UnpinFromProfile") + public static let unpinFromProfile = L10n.tr("Localizable", "Common.Controls.Status.Actions.UnpinFromProfile", fallback: "Unpin from Profile") /// Vote - public static let vote = L10n.tr("Localizable", "Common.Controls.Status.Actions.Vote") + public static let vote = L10n.tr("Localizable", "Common.Controls.Status.Actions.Vote", fallback: "Vote") } public enum Poll { /// Closed - public static let expired = L10n.tr("Localizable", "Common.Controls.Status.Poll.Expired") + public static let expired = L10n.tr("Localizable", "Common.Controls.Status.Poll.Expired", fallback: "Closed") /// %@ people public static func totalPeople(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Controls.Status.Poll.TotalPeople", String(describing: p1)) + return L10n.tr("Localizable", "Common.Controls.Status.Poll.TotalPeople", String(describing: p1), fallback: "%@ people") } /// %@ person public static func totalPerson(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Controls.Status.Poll.TotalPerson", String(describing: p1)) + return L10n.tr("Localizable", "Common.Controls.Status.Poll.TotalPerson", String(describing: p1), fallback: "%@ person") } /// %@ vote public static func totalVote(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Controls.Status.Poll.TotalVote", String(describing: p1)) + return L10n.tr("Localizable", "Common.Controls.Status.Poll.TotalVote", String(describing: p1), fallback: "%@ vote") } /// %@ votes public static func totalVotes(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Controls.Status.Poll.TotalVotes", String(describing: p1)) + return L10n.tr("Localizable", "Common.Controls.Status.Poll.TotalVotes", String(describing: p1), fallback: "%@ votes") } } public enum ReplySettings { /// People %@ follows or mentioned can reply. public static func peopleUserFollowsOrMentionedCanReply(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Controls.Status.ReplySettings.PeopleUserFollowsOrMentionedCanReply", String(describing: p1)) + return L10n.tr("Localizable", "Common.Controls.Status.ReplySettings.PeopleUserFollowsOrMentionedCanReply", String(describing: p1), fallback: "People %@ follows or mentioned can reply.") } /// People %@ mentioned can reply. public static func peopleUserMentionedCanReply(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Controls.Status.ReplySettings.PeopleUserMentionedCanReply", String(describing: p1)) + return L10n.tr("Localizable", "Common.Controls.Status.ReplySettings.PeopleUserMentionedCanReply", String(describing: p1), fallback: "People %@ mentioned can reply.") } } public enum Thread { /// Show this thread - public static let show = L10n.tr("Localizable", "Common.Controls.Status.Thread.Show") + public static let show = L10n.tr("Localizable", "Common.Controls.Status.Thread.Show", fallback: "Show this thread") } } public enum Timeline { /// Load More - public static let loadMore = L10n.tr("Localizable", "Common.Controls.Timeline.LoadMore") + public static let loadMore = L10n.tr("Localizable", "Common.Controls.Timeline.LoadMore", fallback: "Load More") } public enum User { public enum Actions { /// Add/remove from Lists - public static let addRemoveFromLists = L10n.tr("Localizable", "Common.Controls.User.Actions.AddRemoveFromLists") + public static let addRemoveFromLists = L10n.tr("Localizable", "Common.Controls.User.Actions.AddRemoveFromLists", fallback: "Add/remove from Lists") /// View Listed - public static let viewListed = L10n.tr("Localizable", "Common.Controls.User.Actions.ViewListed") + public static let viewListed = L10n.tr("Localizable", "Common.Controls.User.Actions.ViewListed", fallback: "View Listed") /// View Lists - public static let viewLists = L10n.tr("Localizable", "Common.Controls.User.Actions.ViewLists") + public static let viewLists = L10n.tr("Localizable", "Common.Controls.User.Actions.ViewLists", fallback: "View Lists") } } } @@ -765,953 +787,1040 @@ public enum L10n { public enum Like { /// %@ likes public static func multiple(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Countable.Like.Multiple", String(describing: p1)) + return L10n.tr("Localizable", "Common.Countable.Like.Multiple", String(describing: p1), fallback: "%@ likes") } /// %@ like public static func single(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Countable.Like.Single", String(describing: p1)) + return L10n.tr("Localizable", "Common.Countable.Like.Single", String(describing: p1), fallback: "%@ like") } } public enum List { /// %@ lists public static func multiple(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Countable.List.Multiple", String(describing: p1)) + return L10n.tr("Localizable", "Common.Countable.List.Multiple", String(describing: p1), fallback: "%@ lists") } /// %@ list public static func single(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Countable.List.Single", String(describing: p1)) + return L10n.tr("Localizable", "Common.Countable.List.Single", String(describing: p1), fallback: "%@ list") } } public enum Member { /// %@ members public static func multiple(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Countable.Member.Multiple", String(describing: p1)) + return L10n.tr("Localizable", "Common.Countable.Member.Multiple", String(describing: p1), fallback: "%@ members") } /// %@ member public static func single(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Countable.Member.Single", String(describing: p1)) + return L10n.tr("Localizable", "Common.Countable.Member.Single", String(describing: p1), fallback: "%@ member") } } public enum Photo { /// %@ photos public static func multiple(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Countable.Photo.Multiple", String(describing: p1)) + return L10n.tr("Localizable", "Common.Countable.Photo.Multiple", String(describing: p1), fallback: "%@ photos") } /// %@ photo public static func single(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Countable.Photo.Single", String(describing: p1)) + return L10n.tr("Localizable", "Common.Countable.Photo.Single", String(describing: p1), fallback: "%@ photo") } } public enum Quote { /// %@ quotes public static func mutiple(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Countable.Quote.Mutiple", String(describing: p1)) + return L10n.tr("Localizable", "Common.Countable.Quote.Mutiple", String(describing: p1), fallback: "%@ quotes") } /// %@ quote public static func single(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Countable.Quote.Single", String(describing: p1)) + return L10n.tr("Localizable", "Common.Countable.Quote.Single", String(describing: p1), fallback: "%@ quote") } } public enum Reply { /// %@ replies public static func mutiple(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Countable.Reply.Mutiple", String(describing: p1)) + return L10n.tr("Localizable", "Common.Countable.Reply.Mutiple", String(describing: p1), fallback: "%@ replies") } /// %@ reply public static func single(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Countable.Reply.Single", String(describing: p1)) + return L10n.tr("Localizable", "Common.Countable.Reply.Single", String(describing: p1), fallback: "%@ reply") } } public enum Retweet { /// %@ retweets public static func mutiple(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Countable.Retweet.Mutiple", String(describing: p1)) + return L10n.tr("Localizable", "Common.Countable.Retweet.Mutiple", String(describing: p1), fallback: "%@ retweets") } /// %@ retweet public static func single(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Countable.Retweet.Single", String(describing: p1)) + return L10n.tr("Localizable", "Common.Countable.Retweet.Single", String(describing: p1), fallback: "%@ retweet") } } public enum Tweet { /// %@ tweets public static func multiple(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Countable.Tweet.Multiple", String(describing: p1)) + return L10n.tr("Localizable", "Common.Countable.Tweet.Multiple", String(describing: p1), fallback: "%@ tweets") } /// %@ tweet public static func single(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Countable.Tweet.Single", String(describing: p1)) + return L10n.tr("Localizable", "Common.Countable.Tweet.Single", String(describing: p1), fallback: "%@ tweet") } } } public enum Notification { /// %@ favourited your toot public static func favourite(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Notification.Favourite", String(describing: p1)) + return L10n.tr("Localizable", "Common.Notification.Favourite", String(describing: p1), fallback: "%@ favourited your toot") } /// %@ followed you public static func follow(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Notification.Follow", String(describing: p1)) + return L10n.tr("Localizable", "Common.Notification.Follow", String(describing: p1), fallback: "%@ followed you") } /// %@ has requested to follow you public static func followRequest(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Notification.FollowRequest", String(describing: p1)) + return L10n.tr("Localizable", "Common.Notification.FollowRequest", String(describing: p1), fallback: "%@ has requested to follow you") } /// %@ mentions you public static func mentions(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Notification.Mentions", String(describing: p1)) + return L10n.tr("Localizable", "Common.Notification.Mentions", String(describing: p1), fallback: "%@ mentions you") } /// Your poll has ended - public static let ownPoll = L10n.tr("Localizable", "Common.Notification.OwnPoll") + public static let ownPoll = L10n.tr("Localizable", "Common.Notification.OwnPoll", fallback: "Your poll has ended") /// A poll you have voted in has ended - public static let poll = L10n.tr("Localizable", "Common.Notification.Poll") + public static let poll = L10n.tr("Localizable", "Common.Notification.Poll", fallback: "A poll you have voted in has ended") /// %@ boosted your toot public static func reblog(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Notification.Reblog", String(describing: p1)) + return L10n.tr("Localizable", "Common.Notification.Reblog", String(describing: p1), fallback: "%@ boosted your toot") } /// %@ just posted public static func status(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Notification.Status", String(describing: p1)) + return L10n.tr("Localizable", "Common.Notification.Status", String(describing: p1), fallback: "%@ just posted") } public enum FollowRequestAction { /// Approve - public static let approve = L10n.tr("Localizable", "Common.Notification.FollowRequestAction.Approve") + public static let approve = L10n.tr("Localizable", "Common.Notification.FollowRequestAction.Approve", fallback: "Approve") /// Deny - public static let deny = L10n.tr("Localizable", "Common.Notification.FollowRequestAction.Deny") + public static let deny = L10n.tr("Localizable", "Common.Notification.FollowRequestAction.Deny", fallback: "Deny") } public enum FollowRequestResponse { /// Follow Request Approved - public static let followRequestApproved = L10n.tr("Localizable", "Common.Notification.FollowRequestResponse.FollowRequestApproved") + public static let followRequestApproved = L10n.tr("Localizable", "Common.Notification.FollowRequestResponse.FollowRequestApproved", fallback: "Follow Request Approved") /// Follow Request Denied - public static let followRequestDenied = L10n.tr("Localizable", "Common.Notification.FollowRequestResponse.FollowRequestDenied") + public static let followRequestDenied = L10n.tr("Localizable", "Common.Notification.FollowRequestResponse.FollowRequestDenied", fallback: "Follow Request Denied") } public enum Messages { /// %@ sent you a message public static func content(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Notification.Messages.Content", String(describing: p1)) + return L10n.tr("Localizable", "Common.Notification.Messages.Content", String(describing: p1), fallback: "%@ sent you a message") } /// New direct message - public static let title = L10n.tr("Localizable", "Common.Notification.Messages.Title") + public static let title = L10n.tr("Localizable", "Common.Notification.Messages.Title", fallback: "New direct message") } } public enum NotificationChannel { public enum BackgroundProgresses { /// Background progresses - public static let name = L10n.tr("Localizable", "Common.NotificationChannel.BackgroundProgresses.Name") + public static let name = L10n.tr("Localizable", "Common.NotificationChannel.BackgroundProgresses.Name", fallback: "Background progresses") } public enum ContentInteractions { /// Interactions like mentions and retweets - public static let description = L10n.tr("Localizable", "Common.NotificationChannel.ContentInteractions.Description") + public static let description = L10n.tr("Localizable", "Common.NotificationChannel.ContentInteractions.Description", fallback: "Interactions like mentions and retweets") /// Interactions - public static let name = L10n.tr("Localizable", "Common.NotificationChannel.ContentInteractions.Name") + public static let name = L10n.tr("Localizable", "Common.NotificationChannel.ContentInteractions.Name", fallback: "Interactions") } public enum ContentMessages { /// Direct messages - public static let description = L10n.tr("Localizable", "Common.NotificationChannel.ContentMessages.Description") + public static let description = L10n.tr("Localizable", "Common.NotificationChannel.ContentMessages.Description", fallback: "Direct messages") /// Messages - public static let name = L10n.tr("Localizable", "Common.NotificationChannel.ContentMessages.Name") + public static let name = L10n.tr("Localizable", "Common.NotificationChannel.ContentMessages.Name", fallback: "Messages") } } } - public enum Scene { public enum Authentication { /// Authentication - public static let title = L10n.tr("Localizable", "Scene.Authentication.Title") + public static let title = L10n.tr("Localizable", "Scene.Authentication.Title", fallback: "Authentication") } public enum Bookmark { /// Bookmark - public static let title = L10n.tr("Localizable", "Scene.Bookmark.Title") + public static let title = L10n.tr("Localizable", "Scene.Bookmark.Title", fallback: "Bookmark") + } + public enum Column { + /// New Column + public static let title = L10n.tr("Localizable", "Scene.Column.Title", fallback: "New Column") + public enum Actions { + /// Close column + public static let closeColumn = L10n.tr("Localizable", "Scene.Column.Actions.CloseColumn", fallback: "Close column") + /// Move left + public static let moveLeft = L10n.tr("Localizable", "Scene.Column.Actions.MoveLeft", fallback: "Move left") + /// Move right + public static let moveRight = L10n.tr("Localizable", "Scene.Column.Actions.MoveRight", fallback: "Move right") + /// Open in new column + public static let openInNewColumn = L10n.tr("Localizable", "Scene.Column.Actions.OpenInNewColumn", fallback: "Open in new column") + } } public enum Compose { /// , - public static let and = L10n.tr("Localizable", "Scene.Compose.And") + public static let and = L10n.tr("Localizable", "Scene.Compose.And", fallback: ", ") /// Write your warning here - public static let cwPlaceholder = L10n.tr("Localizable", "Scene.Compose.CwPlaceholder") + public static let cwPlaceholder = L10n.tr("Localizable", "Scene.Compose.CwPlaceholder", fallback: "Write your warning here") /// and - public static let lastEnd = L10n.tr("Localizable", "Scene.Compose.LastEnd") + public static let lastEnd = L10n.tr("Localizable", "Scene.Compose.LastEnd", fallback: " and ") /// Others in this conversation: - public static let othersInThisConversation = L10n.tr("Localizable", "Scene.Compose.OthersInThisConversation") + public static let othersInThisConversation = L10n.tr("Localizable", "Scene.Compose.OthersInThisConversation", fallback: "Others in this conversation:") /// What’s happening? - public static let placeholder = L10n.tr("Localizable", "Scene.Compose.Placeholder") + public static let placeholder = L10n.tr("Localizable", "Scene.Compose.Placeholder", fallback: "What’s happening?") /// Replying to - public static let replyingTo = L10n.tr("Localizable", "Scene.Compose.ReplyingTo") + public static let replyingTo = L10n.tr("Localizable", "Scene.Compose.ReplyingTo", fallback: "Replying to") /// Reply to … - public static let replyTo = L10n.tr("Localizable", "Scene.Compose.ReplyTo") + public static let replyTo = L10n.tr("Localizable", "Scene.Compose.ReplyTo", fallback: "Reply to …") public enum Media { /// Preview - public static let preview = L10n.tr("Localizable", "Scene.Compose.Media.Preview") + public static let preview = L10n.tr("Localizable", "Scene.Compose.Media.Preview", fallback: "Preview") /// Remove - public static let remove = L10n.tr("Localizable", "Scene.Compose.Media.Remove") + public static let remove = L10n.tr("Localizable", "Scene.Compose.Media.Remove", fallback: "Remove") public enum Caption { /// Add Caption - public static let add = L10n.tr("Localizable", "Scene.Compose.Media.Caption.Add") + public static let add = L10n.tr("Localizable", "Scene.Compose.Media.Caption.Add", fallback: "Add Caption") /// Add a description for this image - public static let addADescriptionForThisImage = L10n.tr("Localizable", "Scene.Compose.Media.Caption.AddADescriptionForThisImage") + public static let addADescriptionForThisImage = L10n.tr("Localizable", "Scene.Compose.Media.Caption.AddADescriptionForThisImage", fallback: "Add a description for this image") /// Remove Caption - public static let remove = L10n.tr("Localizable", "Scene.Compose.Media.Caption.Remove") + public static let remove = L10n.tr("Localizable", "Scene.Compose.Media.Caption.Remove", fallback: "Remove Caption") /// Update Caption - public static let update = L10n.tr("Localizable", "Scene.Compose.Media.Caption.Update") + public static let update = L10n.tr("Localizable", "Scene.Compose.Media.Caption.Update", fallback: "Update Caption") } } public enum ReplySettings { - /// Everyone can peply - public static let everyoneCanReply = L10n.tr("Localizable", "Scene.Compose.ReplySettings.EveryoneCanReply") + /// Everyone can reply + public static let everyoneCanReply = L10n.tr("Localizable", "Scene.Compose.ReplySettings.EveryoneCanReply", fallback: "Everyone can reply") /// Only people you mention can reply - public static let onlyPeopleYouMentionCanReply = L10n.tr("Localizable", "Scene.Compose.ReplySettings.OnlyPeopleYouMentionCanReply") + public static let onlyPeopleYouMentionCanReply = L10n.tr("Localizable", "Scene.Compose.ReplySettings.OnlyPeopleYouMentionCanReply", fallback: "Only people you mention can reply") /// People you follow can reply - public static let peopleYouFollowCanReply = L10n.tr("Localizable", "Scene.Compose.ReplySettings.PeopleYouFollowCanReply") + public static let peopleYouFollowCanReply = L10n.tr("Localizable", "Scene.Compose.ReplySettings.PeopleYouFollowCanReply", fallback: "People you follow can reply") } public enum SaveDraft { /// Save draft - public static let action = L10n.tr("Localizable", "Scene.Compose.SaveDraft.Action") + public static let action = L10n.tr("Localizable", "Scene.Compose.SaveDraft.Action", fallback: "Save draft") /// Save draft? - public static let message = L10n.tr("Localizable", "Scene.Compose.SaveDraft.Message") + public static let message = L10n.tr("Localizable", "Scene.Compose.SaveDraft.Message", fallback: "Save draft?") } public enum Title { /// Compose - public static let compose = L10n.tr("Localizable", "Scene.Compose.Title.Compose") + public static let compose = L10n.tr("Localizable", "Scene.Compose.Title.Compose", fallback: "Compose") /// Quote - public static let quote = L10n.tr("Localizable", "Scene.Compose.Title.Quote") + public static let quote = L10n.tr("Localizable", "Scene.Compose.Title.Quote", fallback: "Quote") /// Reply - public static let reply = L10n.tr("Localizable", "Scene.Compose.Title.Reply") + public static let reply = L10n.tr("Localizable", "Scene.Compose.Title.Reply", fallback: "Reply") } public enum Visibility { /// Direct - public static let direct = L10n.tr("Localizable", "Scene.Compose.Visibility.Direct") + public static let direct = L10n.tr("Localizable", "Scene.Compose.Visibility.Direct", fallback: "Direct") /// Private - public static let `private` = L10n.tr("Localizable", "Scene.Compose.Visibility.Private") + public static let `private` = L10n.tr("Localizable", "Scene.Compose.Visibility.Private", fallback: "Private") /// Public - public static let `public` = L10n.tr("Localizable", "Scene.Compose.Visibility.Public") + public static let `public` = L10n.tr("Localizable", "Scene.Compose.Visibility.Public", fallback: "Public") /// Unlisted - public static let unlisted = L10n.tr("Localizable", "Scene.Compose.Visibility.Unlisted") + public static let unlisted = L10n.tr("Localizable", "Scene.Compose.Visibility.Unlisted", fallback: "Unlisted") } public enum VisibilityDescription { /// Visible for mentioned users only - public static let direct = L10n.tr("Localizable", "Scene.Compose.VisibilityDescription.Direct") + public static let direct = L10n.tr("Localizable", "Scene.Compose.VisibilityDescription.Direct", fallback: "Visible for mentioned users only") /// Visible for followers only - public static let `private` = L10n.tr("Localizable", "Scene.Compose.VisibilityDescription.Private") + public static let `private` = L10n.tr("Localizable", "Scene.Compose.VisibilityDescription.Private", fallback: "Visible for followers only") /// Visible for all, shown in public timelines - public static let `public` = L10n.tr("Localizable", "Scene.Compose.VisibilityDescription.Public") + public static let `public` = L10n.tr("Localizable", "Scene.Compose.VisibilityDescription.Public", fallback: "Visible for all, shown in public timelines") /// Visible for all, but not in public timelines - public static let unlisted = L10n.tr("Localizable", "Scene.Compose.VisibilityDescription.Unlisted") + public static let unlisted = L10n.tr("Localizable", "Scene.Compose.VisibilityDescription.Unlisted", fallback: "Visible for all, but not in public timelines") } public enum Vote { /// Multiple choice - public static let multiple = L10n.tr("Localizable", "Scene.Compose.Vote.Multiple") + public static let multiple = L10n.tr("Localizable", "Scene.Compose.Vote.Multiple", fallback: "Multiple choice") /// Choice %d public static func placeholderIndex(_ p1: Int) -> String { - return L10n.tr("Localizable", "Scene.Compose.Vote.PlaceholderIndex", p1) + return L10n.tr("Localizable", "Scene.Compose.Vote.PlaceholderIndex", p1, fallback: "Choice %d") } public enum Expiration { /// 1 day - public static let _1Day = L10n.tr("Localizable", "Scene.Compose.Vote.Expiration.1Day") + public static let _1Day = L10n.tr("Localizable", "Scene.Compose.Vote.Expiration.1Day", fallback: "1 day") /// 1 hour - public static let _1Hour = L10n.tr("Localizable", "Scene.Compose.Vote.Expiration.1Hour") + public static let _1Hour = L10n.tr("Localizable", "Scene.Compose.Vote.Expiration.1Hour", fallback: "1 hour") /// 30 minutes - public static let _30Min = L10n.tr("Localizable", "Scene.Compose.Vote.Expiration.30Min") + public static let _30Min = L10n.tr("Localizable", "Scene.Compose.Vote.Expiration.30Min", fallback: "30 minutes") /// 3 days - public static let _3Day = L10n.tr("Localizable", "Scene.Compose.Vote.Expiration.3Day") + public static let _3Day = L10n.tr("Localizable", "Scene.Compose.Vote.Expiration.3Day", fallback: "3 days") /// 5 minutes - public static let _5Min = L10n.tr("Localizable", "Scene.Compose.Vote.Expiration.5Min") + public static let _5Min = L10n.tr("Localizable", "Scene.Compose.Vote.Expiration.5Min", fallback: "5 minutes") /// 6 hours - public static let _6Hour = L10n.tr("Localizable", "Scene.Compose.Vote.Expiration.6Hour") + public static let _6Hour = L10n.tr("Localizable", "Scene.Compose.Vote.Expiration.6Hour", fallback: "6 hours") /// 7 days - public static let _7Day = L10n.tr("Localizable", "Scene.Compose.Vote.Expiration.7Day") + public static let _7Day = L10n.tr("Localizable", "Scene.Compose.Vote.Expiration.7Day", fallback: "7 days") } } } public enum ComposeHashtagSearch { /// Search hashtag - public static let searchPlaceholder = L10n.tr("Localizable", "Scene.ComposeHashtagSearch.SearchPlaceholder") + public static let searchPlaceholder = L10n.tr("Localizable", "Scene.ComposeHashtagSearch.SearchPlaceholder", fallback: "Search hashtag") } public enum ComposeUserSearch { /// Search users - public static let searchPlaceholder = L10n.tr("Localizable", "Scene.ComposeUserSearch.SearchPlaceholder") + 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") + public static let title = L10n.tr("Localizable", "Scene.Drafts.Title", fallback: "Drafts") public enum Actions { /// Delete draft - public static let deleteDraft = L10n.tr("Localizable", "Scene.Drafts.Actions.DeleteDraft") + public static let deleteDraft = L10n.tr("Localizable", "Scene.Drafts.Actions.DeleteDraft", fallback: "Delete draft") /// Edit draft - public static let editDraft = L10n.tr("Localizable", "Scene.Drafts.Actions.EditDraft") + public static let editDraft = L10n.tr("Localizable", "Scene.Drafts.Actions.EditDraft", fallback: "Edit draft") } } public enum Drawer { /// Manage accounts - public static let manageAccounts = L10n.tr("Localizable", "Scene.Drawer.ManageAccounts") + public static let manageAccounts = L10n.tr("Localizable", "Scene.Drawer.ManageAccounts", fallback: "Manage accounts") /// Sign in - public static let signIn = L10n.tr("Localizable", "Scene.Drawer.SignIn") + public static let signIn = L10n.tr("Localizable", "Scene.Drawer.SignIn", fallback: "Sign in") } public enum Federated { /// Federated - public static let title = L10n.tr("Localizable", "Scene.Federated.Title") + public static let title = L10n.tr("Localizable", "Scene.Federated.Title", fallback: "Federated") } public enum Followers { /// Followers - public static let title = L10n.tr("Localizable", "Scene.Followers.Title") + public static let title = L10n.tr("Localizable", "Scene.Followers.Title", fallback: "Followers") } public enum Following { /// Following - public static let title = L10n.tr("Localizable", "Scene.Following.Title") + public static let title = L10n.tr("Localizable", "Scene.Following.Title", fallback: "Following") + } + public enum History { + /// Clear + public static let clear = L10n.tr("Localizable", "Scene.History.Clear", fallback: "Clear") + /// History + public static let title = L10n.tr("Localizable", "Scene.History.Title", fallback: "History") + public enum Scope { + /// Toot + public static let toot = L10n.tr("Localizable", "Scene.History.Scope.Toot", fallback: "Toot") + /// Tweet + public static let tweet = L10n.tr("Localizable", "Scene.History.Scope.Tweet", fallback: "Tweet") + /// User + public static let user = L10n.tr("Localizable", "Scene.History.Scope.User", fallback: "User") + } } public enum Likes { /// Likes - public static let title = L10n.tr("Localizable", "Scene.Likes.Title") + public static let title = L10n.tr("Localizable", "Scene.Likes.Title", fallback: "Likes") } public enum Listed { /// Listed - public static let title = L10n.tr("Localizable", "Scene.Listed.Title") + public static let title = L10n.tr("Localizable", "Scene.Listed.Title", fallback: "Listed") } public enum Lists { /// Lists - public static let title = L10n.tr("Localizable", "Scene.Lists.Title") + public static let title = L10n.tr("Localizable", "Scene.Lists.Title", fallback: "Lists") public enum Icons { /// Create list - public static let create = L10n.tr("Localizable", "Scene.Lists.Icons.Create") + public static let create = L10n.tr("Localizable", "Scene.Lists.Icons.Create", fallback: "Create list") /// Private visibility - public static let `private` = L10n.tr("Localizable", "Scene.Lists.Icons.Private") + public static let `private` = L10n.tr("Localizable", "Scene.Lists.Icons.Private", fallback: "Private visibility") } public enum Tabs { /// MY LISTS - public static let created = L10n.tr("Localizable", "Scene.Lists.Tabs.Created") + public static let created = L10n.tr("Localizable", "Scene.Lists.Tabs.Created", fallback: "MY LISTS") /// SUBSCRIBED - public static let subscribed = L10n.tr("Localizable", "Scene.Lists.Tabs.Subscribed") + public static let subscribed = L10n.tr("Localizable", "Scene.Lists.Tabs.Subscribed", fallback: "SUBSCRIBED") } } public enum ListsDetails { /// Add Members - public static let addMembers = L10n.tr("Localizable", "Scene.ListsDetails.AddMembers") + public static let addMembers = L10n.tr("Localizable", "Scene.ListsDetails.AddMembers", fallback: "Add Members") /// Delete this list: %@ public static func deleteListConfirm(_ p1: Any) -> String { - return L10n.tr("Localizable", "Scene.ListsDetails.DeleteListConfirm", String(describing: p1)) + return L10n.tr("Localizable", "Scene.ListsDetails.DeleteListConfirm", String(describing: p1), fallback: "Delete this list: %@") } /// Delete this list - public static let deleteListTitle = L10n.tr("Localizable", "Scene.ListsDetails.DeleteListTitle") + public static let deleteListTitle = L10n.tr("Localizable", "Scene.ListsDetails.DeleteListTitle", fallback: "Delete this list") /// No Members Found. - public static let noMembersFound = L10n.tr("Localizable", "Scene.ListsDetails.NoMembersFound") + public static let noMembersFound = L10n.tr("Localizable", "Scene.ListsDetails.NoMembersFound", fallback: "No Members Found.") /// Lists Details - public static let title = L10n.tr("Localizable", "Scene.ListsDetails.Title") + public static let title = L10n.tr("Localizable", "Scene.ListsDetails.Title", fallback: "Lists Details") public enum Descriptions { /// %d Members public static func multipleMembers(_ p1: Int) -> String { - return L10n.tr("Localizable", "Scene.ListsDetails.Descriptions.MultipleMembers", p1) + return L10n.tr("Localizable", "Scene.ListsDetails.Descriptions.MultipleMembers", p1, fallback: "%d Members") } /// %d Subscribers public static func multipleSubscribers(_ p1: Int) -> String { - return L10n.tr("Localizable", "Scene.ListsDetails.Descriptions.MultipleSubscribers", p1) + return L10n.tr("Localizable", "Scene.ListsDetails.Descriptions.MultipleSubscribers", p1, fallback: "%d Subscribers") } /// 1 Member - public static let singleMember = L10n.tr("Localizable", "Scene.ListsDetails.Descriptions.SingleMember") + public static let singleMember = L10n.tr("Localizable", "Scene.ListsDetails.Descriptions.SingleMember", fallback: "1 Member") /// 1 Subscriber - public static let singleSubscriber = L10n.tr("Localizable", "Scene.ListsDetails.Descriptions.SingleSubscriber") + public static let singleSubscriber = L10n.tr("Localizable", "Scene.ListsDetails.Descriptions.SingleSubscriber", fallback: "1 Subscriber") } public enum MenuActions { /// Add Member - public static let addMember = L10n.tr("Localizable", "Scene.ListsDetails.MenuActions.AddMember") + public static let addMember = L10n.tr("Localizable", "Scene.ListsDetails.MenuActions.AddMember", fallback: "Add Member") /// Delete List - public static let deleteList = L10n.tr("Localizable", "Scene.ListsDetails.MenuActions.DeleteList") + public static let deleteList = L10n.tr("Localizable", "Scene.ListsDetails.MenuActions.DeleteList", fallback: "Delete List") /// Edit List - public static let editList = L10n.tr("Localizable", "Scene.ListsDetails.MenuActions.EditList") + public static let editList = L10n.tr("Localizable", "Scene.ListsDetails.MenuActions.EditList", fallback: "Edit List") /// Follow - public static let follow = L10n.tr("Localizable", "Scene.ListsDetails.MenuActions.Follow") + public static let follow = L10n.tr("Localizable", "Scene.ListsDetails.MenuActions.Follow", fallback: "Follow") /// Rename List - public static let renameList = L10n.tr("Localizable", "Scene.ListsDetails.MenuActions.RenameList") + public static let renameList = L10n.tr("Localizable", "Scene.ListsDetails.MenuActions.RenameList", fallback: "Rename List") /// Unfollow - public static let unfollow = L10n.tr("Localizable", "Scene.ListsDetails.MenuActions.Unfollow") + public static let unfollow = L10n.tr("Localizable", "Scene.ListsDetails.MenuActions.Unfollow", fallback: "Unfollow") } public enum Tabs { /// List Members - public static let members = L10n.tr("Localizable", "Scene.ListsDetails.Tabs.Members") + public static let members = L10n.tr("Localizable", "Scene.ListsDetails.Tabs.Members", fallback: "List Members") /// Subscribers - public static let subscriber = L10n.tr("Localizable", "Scene.ListsDetails.Tabs.Subscriber") + public static let subscriber = L10n.tr("Localizable", "Scene.ListsDetails.Tabs.Subscriber", fallback: "Subscribers") } } public enum ListsModify { /// Description - public static let description = L10n.tr("Localizable", "Scene.ListsModify.Description") + public static let description = L10n.tr("Localizable", "Scene.ListsModify.Description", fallback: "Description") /// Name - public static let name = L10n.tr("Localizable", "Scene.ListsModify.Name") + public static let name = L10n.tr("Localizable", "Scene.ListsModify.Name", fallback: "Name") /// Private - public static let `private` = L10n.tr("Localizable", "Scene.ListsModify.Private") + public static let `private` = L10n.tr("Localizable", "Scene.ListsModify.Private", fallback: "Private") public enum Create { /// New List - public static let title = L10n.tr("Localizable", "Scene.ListsModify.Create.Title") + public static let title = L10n.tr("Localizable", "Scene.ListsModify.Create.Title", fallback: "New List") } public enum Dialog { /// Create a list - public static let create = L10n.tr("Localizable", "Scene.ListsModify.Dialog.Create") + public static let create = L10n.tr("Localizable", "Scene.ListsModify.Dialog.Create", fallback: "Create a list") /// Rename the list - public static let edit = L10n.tr("Localizable", "Scene.ListsModify.Dialog.Edit") + public static let edit = L10n.tr("Localizable", "Scene.ListsModify.Dialog.Edit", fallback: "Rename the list") } public enum Edit { /// Edit List - public static let title = L10n.tr("Localizable", "Scene.ListsModify.Edit.Title") + public static let title = L10n.tr("Localizable", "Scene.ListsModify.Edit.Title", fallback: "Edit List") } } public enum ListsUsers { public enum Add { /// Search people - public static let search = L10n.tr("Localizable", "Scene.ListsUsers.Add.Search") + public static let search = L10n.tr("Localizable", "Scene.ListsUsers.Add.Search", fallback: "Search people") /// Search within people you follow - public static let searchWithinPeopleYouFollow = L10n.tr("Localizable", "Scene.ListsUsers.Add.SearchWithinPeopleYouFollow") + public static let searchWithinPeopleYouFollow = L10n.tr("Localizable", "Scene.ListsUsers.Add.SearchWithinPeopleYouFollow", fallback: "Search within people you follow") /// Add Member - public static let title = L10n.tr("Localizable", "Scene.ListsUsers.Add.Title") + public static let title = L10n.tr("Localizable", "Scene.ListsUsers.Add.Title", fallback: "Add Member") } public enum MenuActions { /// Add - public static let add = L10n.tr("Localizable", "Scene.ListsUsers.MenuActions.Add") + public static let add = L10n.tr("Localizable", "Scene.ListsUsers.MenuActions.Add", fallback: "Add") /// Remove - public static let remove = L10n.tr("Localizable", "Scene.ListsUsers.MenuActions.Remove") + public static let remove = L10n.tr("Localizable", "Scene.ListsUsers.MenuActions.Remove", fallback: "Remove") } } public enum Local { /// Local - public static let title = L10n.tr("Localizable", "Scene.Local.Title") + public static let title = L10n.tr("Localizable", "Scene.Local.Title", fallback: "Local") } public enum ManageAccounts { /// Delete account - public static let deleteAccount = L10n.tr("Localizable", "Scene.ManageAccounts.DeleteAccount") + public static let deleteAccount = L10n.tr("Localizable", "Scene.ManageAccounts.DeleteAccount", fallback: "Delete account") + /// Open in new window + public static let openInNewWindow = L10n.tr("Localizable", "Scene.ManageAccounts.OpenInNewWindow", fallback: "Open in new window") /// Accounts - public static let title = L10n.tr("Localizable", "Scene.ManageAccounts.Title") + public static let title = L10n.tr("Localizable", "Scene.ManageAccounts.Title", fallback: "Accounts") } public enum Mentions { /// Mentions - public static let title = L10n.tr("Localizable", "Scene.Mentions.Title") + public static let title = L10n.tr("Localizable", "Scene.Mentions.Title", fallback: "Mentions") } public enum Messages { /// Messages - public static let title = L10n.tr("Localizable", "Scene.Messages.Title") + public static let title = L10n.tr("Localizable", "Scene.Messages.Title", fallback: "Messages") public enum Action { /// Copy message text - public static let copyText = L10n.tr("Localizable", "Scene.Messages.Action.CopyText") + public static let copyText = L10n.tr("Localizable", "Scene.Messages.Action.CopyText", fallback: "Copy message text") /// Delete message for you - public static let delete = L10n.tr("Localizable", "Scene.Messages.Action.Delete") + public static let delete = L10n.tr("Localizable", "Scene.Messages.Action.Delete", fallback: "Delete message for you") } public enum Error { /// The Current account does not support direct messages - public static let notSupported = L10n.tr("Localizable", "Scene.Messages.Error.NotSupported") + public static let notSupported = L10n.tr("Localizable", "Scene.Messages.Error.NotSupported", fallback: "The Current account does not support direct messages") } public enum Expanded { /// [Photo] - public static let photo = L10n.tr("Localizable", "Scene.Messages.Expanded.Photo") + public static let photo = L10n.tr("Localizable", "Scene.Messages.Expanded.Photo", fallback: "[Photo]") } public enum Icon { /// Send message failed - public static let failed = L10n.tr("Localizable", "Scene.Messages.Icon.Failed") + public static let failed = L10n.tr("Localizable", "Scene.Messages.Icon.Failed", fallback: "Send message failed") } public enum NewConversation { /// Search people - public static let search = L10n.tr("Localizable", "Scene.Messages.NewConversation.Search") + public static let search = L10n.tr("Localizable", "Scene.Messages.NewConversation.Search", fallback: "Search people") /// Find people - public static let title = L10n.tr("Localizable", "Scene.Messages.NewConversation.Title") + public static let title = L10n.tr("Localizable", "Scene.Messages.NewConversation.Title", fallback: "Find people") } } public enum Notification { /// Notification - public static let title = L10n.tr("Localizable", "Scene.Notification.Title") + public static let title = L10n.tr("Localizable", "Scene.Notification.Title", fallback: "Notification") public enum Tabs { /// All - public static let all = L10n.tr("Localizable", "Scene.Notification.Tabs.All") + public static let all = L10n.tr("Localizable", "Scene.Notification.Tabs.All", fallback: "All") /// Mentions - public static let mentions = L10n.tr("Localizable", "Scene.Notification.Tabs.Mentions") + public static let mentions = L10n.tr("Localizable", "Scene.Notification.Tabs.Mentions", fallback: "Mentions") } } public enum Profile { /// Hide reply - public static let hideReply = L10n.tr("Localizable", "Scene.Profile.HideReply") + public static let hideReply = L10n.tr("Localizable", "Scene.Profile.HideReply", fallback: "Hide reply") /// Me - public static let title = L10n.tr("Localizable", "Scene.Profile.Title") + public static let title = L10n.tr("Localizable", "Scene.Profile.Title", fallback: "Me") public enum Fields { /// Joined in %@ public static func joinedInDate(_ p1: Any) -> String { - return L10n.tr("Localizable", "Scene.Profile.Fields.JoinedInDate", String(describing: p1)) + return L10n.tr("Localizable", "Scene.Profile.Fields.JoinedInDate", String(describing: p1), fallback: "Joined in %@") } } public enum Filter { /// All tweets - public static let all = L10n.tr("Localizable", "Scene.Profile.Filter.All") + public static let all = L10n.tr("Localizable", "Scene.Profile.Filter.All", fallback: "All tweets") /// Exclude replies - public static let excludeReplies = L10n.tr("Localizable", "Scene.Profile.Filter.ExcludeReplies") + public static let excludeReplies = L10n.tr("Localizable", "Scene.Profile.Filter.ExcludeReplies", fallback: "Exclude replies") } public enum PermissionDeniedProfileBlocked { /// You have been blocked from viewing this user’s profile. - public static let message = L10n.tr("Localizable", "Scene.Profile.PermissionDeniedProfileBlocked.Message") + public static let message = L10n.tr("Localizable", "Scene.Profile.PermissionDeniedProfileBlocked.Message", fallback: "You have been blocked from viewing this user’s profile.") /// Permission Denied - public static let title = L10n.tr("Localizable", "Scene.Profile.PermissionDeniedProfileBlocked.Title") + public static let title = L10n.tr("Localizable", "Scene.Profile.PermissionDeniedProfileBlocked.Title", fallback: "Permission Denied") } } public enum Search { /// Saved Search - public static let savedSearch = L10n.tr("Localizable", "Scene.Search.SavedSearch") + public static let savedSearch = L10n.tr("Localizable", "Scene.Search.SavedSearch", fallback: "Saved Search") /// Show less - public static let showLess = L10n.tr("Localizable", "Scene.Search.ShowLess") + public static let showLess = L10n.tr("Localizable", "Scene.Search.ShowLess", fallback: "Show less") /// Show more - public static let showMore = L10n.tr("Localizable", "Scene.Search.ShowMore") + public static let showMore = L10n.tr("Localizable", "Scene.Search.ShowMore", fallback: "Show more") /// Search - public static let title = L10n.tr("Localizable", "Scene.Search.Title") + public static let title = L10n.tr("Localizable", "Scene.Search.Title", fallback: "Search") public enum SearchBar { /// Search tweets or users - public static let placeholder = L10n.tr("Localizable", "Scene.Search.SearchBar.Placeholder") + public static let placeholder = L10n.tr("Localizable", "Scene.Search.SearchBar.Placeholder", fallback: "Search tweets or users") } public enum Tabs { /// Hashtag - public static let hashtag = L10n.tr("Localizable", "Scene.Search.Tabs.Hashtag") + public static let hashtag = L10n.tr("Localizable", "Scene.Search.Tabs.Hashtag", fallback: "Hashtag") /// Media - public static let media = L10n.tr("Localizable", "Scene.Search.Tabs.Media") + public static let media = L10n.tr("Localizable", "Scene.Search.Tabs.Media", fallback: "Media") /// People - public static let people = L10n.tr("Localizable", "Scene.Search.Tabs.People") + public static let people = L10n.tr("Localizable", "Scene.Search.Tabs.People", fallback: "People") /// Toots - public static let toots = L10n.tr("Localizable", "Scene.Search.Tabs.Toots") + public static let toots = L10n.tr("Localizable", "Scene.Search.Tabs.Toots", fallback: "Toots") /// Tweets - public static let tweets = L10n.tr("Localizable", "Scene.Search.Tabs.Tweets") + public static let tweets = L10n.tr("Localizable", "Scene.Search.Tabs.Tweets", fallback: "Tweets") /// Users - public static let users = L10n.tr("Localizable", "Scene.Search.Tabs.Users") + public static let users = L10n.tr("Localizable", "Scene.Search.Tabs.Users", fallback: "Users") } } public enum Settings { /// Settings - public static let title = L10n.tr("Localizable", "Scene.Settings.Title") + public static let title = L10n.tr("Localizable", "Scene.Settings.Title", fallback: "Settings") public enum About { /// Next generation of Twidere for Android 5.0+. /// Still in early stage. - public static let description = L10n.tr("Localizable", "Scene.Settings.About.Description") + public static let description = L10n.tr("Localizable", "Scene.Settings.About.Description", fallback: "Next generation of Twidere for Android 5.0+. \nStill in early stage.") /// License - public static let license = L10n.tr("Localizable", "Scene.Settings.About.License") + public static let license = L10n.tr("Localizable", "Scene.Settings.About.License", fallback: "License") /// About - public static let title = L10n.tr("Localizable", "Scene.Settings.About.Title") + public static let title = L10n.tr("Localizable", "Scene.Settings.About.Title", fallback: "About") /// Ver %@ public static func version(_ p1: Any) -> String { - return L10n.tr("Localizable", "Scene.Settings.About.Version", String(describing: p1)) + return L10n.tr("Localizable", "Scene.Settings.About.Version", String(describing: p1), fallback: "Ver %@") } public enum Logo { /// About page background logo - public static let background = L10n.tr("Localizable", "Scene.Settings.About.Logo.Background") + public static let background = L10n.tr("Localizable", "Scene.Settings.About.Logo.Background", fallback: "About page background logo") /// About page background logo shadow - public static let backgroundShadow = L10n.tr("Localizable", "Scene.Settings.About.Logo.BackgroundShadow") + public static let backgroundShadow = L10n.tr("Localizable", "Scene.Settings.About.Logo.BackgroundShadow", fallback: "About page background logo shadow") } } public enum Account { - /// Account Settings - public static let accountSettings = L10n.tr("Localizable", "Scene.Settings.Account.AccountSettings") /// Blocked People - public static let blockedPeople = L10n.tr("Localizable", "Scene.Settings.Account.BlockedPeople") + public static let blockedPeople = L10n.tr("Localizable", "Scene.Settings.Account.BlockedPeople", fallback: "Blocked People") /// Mute and Block - public static let muteAndBlock = L10n.tr("Localizable", "Scene.Settings.Account.MuteAndBlock") + public static let muteAndBlock = L10n.tr("Localizable", "Scene.Settings.Account.MuteAndBlock", fallback: "Mute and Block") /// Muted People - public static let mutedPeople = L10n.tr("Localizable", "Scene.Settings.Account.MutedPeople") + public static let mutedPeople = L10n.tr("Localizable", "Scene.Settings.Account.MutedPeople", fallback: "Muted People") /// Account - public static let title = L10n.tr("Localizable", "Scene.Settings.Account.Title") + public static let title = L10n.tr("Localizable", "Scene.Settings.Account.Title", fallback: "Account") } public enum Appearance { /// AMOLED optimized mode - public static let amoledOptimizedMode = L10n.tr("Localizable", "Scene.Settings.Appearance.AmoledOptimizedMode") + public static let amoledOptimizedMode = L10n.tr("Localizable", "Scene.Settings.Appearance.AmoledOptimizedMode", fallback: "AMOLED optimized mode") /// App Icon - public static let appIcon = L10n.tr("Localizable", "Scene.Settings.Appearance.AppIcon") + public static let appIcon = L10n.tr("Localizable", "Scene.Settings.Appearance.AppIcon", fallback: "App Icon") /// Highlight color - public static let highlightColor = L10n.tr("Localizable", "Scene.Settings.Appearance.HighlightColor") + public static let highlightColor = L10n.tr("Localizable", "Scene.Settings.Appearance.HighlightColor", fallback: "Highlight color") /// Pick color - public static let pickColor = L10n.tr("Localizable", "Scene.Settings.Appearance.PickColor") + public static let pickColor = L10n.tr("Localizable", "Scene.Settings.Appearance.PickColor", fallback: "Pick color") /// Appearance - public static let title = L10n.tr("Localizable", "Scene.Settings.Appearance.Title") + public static let title = L10n.tr("Localizable", "Scene.Settings.Appearance.Title", fallback: "Appearance") public enum ScrollingTimeline { /// Hide app bar when scrolling - public static let appBar = L10n.tr("Localizable", "Scene.Settings.Appearance.ScrollingTimeline.AppBar") + public static let appBar = L10n.tr("Localizable", "Scene.Settings.Appearance.ScrollingTimeline.AppBar", fallback: "Hide app bar when scrolling") /// Hide FAB when scrolling - public static let fab = L10n.tr("Localizable", "Scene.Settings.Appearance.ScrollingTimeline.Fab") + public static let fab = L10n.tr("Localizable", "Scene.Settings.Appearance.ScrollingTimeline.Fab", fallback: "Hide FAB when scrolling") /// Hide tab bar when scrolling - public static let tabBar = L10n.tr("Localizable", "Scene.Settings.Appearance.ScrollingTimeline.TabBar") + public static let tabBar = L10n.tr("Localizable", "Scene.Settings.Appearance.ScrollingTimeline.TabBar", fallback: "Hide tab bar when scrolling") } public enum SectionHeader { /// Scrolling timeline - public static let scrollingTimeline = L10n.tr("Localizable", "Scene.Settings.Appearance.SectionHeader.ScrollingTimeline") + public static let scrollingTimeline = L10n.tr("Localizable", "Scene.Settings.Appearance.SectionHeader.ScrollingTimeline", fallback: "Scrolling timeline") /// Tab position - public static let tabPosition = L10n.tr("Localizable", "Scene.Settings.Appearance.SectionHeader.TabPosition") + public static let tabPosition = L10n.tr("Localizable", "Scene.Settings.Appearance.SectionHeader.TabPosition", fallback: "Tab position") /// Theme - public static let theme = L10n.tr("Localizable", "Scene.Settings.Appearance.SectionHeader.Theme") + public static let theme = L10n.tr("Localizable", "Scene.Settings.Appearance.SectionHeader.Theme", fallback: "Theme") /// Translation - public static let translation = L10n.tr("Localizable", "Scene.Settings.Appearance.SectionHeader.Translation") + public static let translation = L10n.tr("Localizable", "Scene.Settings.Appearance.SectionHeader.Translation", fallback: "Translation") } public enum TabPosition { /// Bottom - public static let bottom = L10n.tr("Localizable", "Scene.Settings.Appearance.TabPosition.Bottom") + public static let bottom = L10n.tr("Localizable", "Scene.Settings.Appearance.TabPosition.Bottom", fallback: "Bottom") /// Top - public static let top = L10n.tr("Localizable", "Scene.Settings.Appearance.TabPosition.Top") + public static let top = L10n.tr("Localizable", "Scene.Settings.Appearance.TabPosition.Top", fallback: "Top") } public enum Theme { /// Auto - public static let auto = L10n.tr("Localizable", "Scene.Settings.Appearance.Theme.Auto") + public static let auto = L10n.tr("Localizable", "Scene.Settings.Appearance.Theme.Auto", fallback: "Auto") /// Dark - public static let dark = L10n.tr("Localizable", "Scene.Settings.Appearance.Theme.Dark") + public static let dark = L10n.tr("Localizable", "Scene.Settings.Appearance.Theme.Dark", fallback: "Dark") /// Light - public static let light = L10n.tr("Localizable", "Scene.Settings.Appearance.Theme.Light") + public static let light = L10n.tr("Localizable", "Scene.Settings.Appearance.Theme.Light", fallback: "Light") } public enum Translation { /// Always - public static let always = L10n.tr("Localizable", "Scene.Settings.Appearance.Translation.Always") + public static let always = L10n.tr("Localizable", "Scene.Settings.Appearance.Translation.Always", fallback: "Always") /// Auto - public static let auto = L10n.tr("Localizable", "Scene.Settings.Appearance.Translation.Auto") + public static let auto = L10n.tr("Localizable", "Scene.Settings.Appearance.Translation.Auto", fallback: "Auto") /// Off - public static let off = L10n.tr("Localizable", "Scene.Settings.Appearance.Translation.Off") + public static let off = L10n.tr("Localizable", "Scene.Settings.Appearance.Translation.Off", fallback: "Off") /// Service - public static let service = L10n.tr("Localizable", "Scene.Settings.Appearance.Translation.Service") + public static let service = L10n.tr("Localizable", "Scene.Settings.Appearance.Translation.Service", fallback: "Service") /// Translate button - public static let translateButton = L10n.tr("Localizable", "Scene.Settings.Appearance.Translation.TranslateButton") + public static let translateButton = L10n.tr("Localizable", "Scene.Settings.Appearance.Translation.TranslateButton", fallback: "Translate button") + } + } + public enum Behaviors { + /// Behaviors + public static let title = L10n.tr("Localizable", "Scene.Settings.Behaviors.Title", fallback: "Behaviors") + public enum HistorySection { + /// Enable History Record + public static let enableHistoryRecord = L10n.tr("Localizable", "Scene.Settings.Behaviors.HistorySection.EnableHistoryRecord", fallback: "Enable History Record") + /// History + public static let history = L10n.tr("Localizable", "Scene.Settings.Behaviors.HistorySection.History", fallback: "History") + } + public enum TabBarSection { + /// Show tab bar labels + public static let showTabBarLabels = L10n.tr("Localizable", "Scene.Settings.Behaviors.TabBarSection.ShowTabBarLabels", fallback: "Show tab bar labels") + /// Tab Bar + public static let tabBar = L10n.tr("Localizable", "Scene.Settings.Behaviors.TabBarSection.TabBar", fallback: "Tab Bar") + /// Tap tab bar scroll to top + public static let tapTabBarScrollToTop = L10n.tr("Localizable", "Scene.Settings.Behaviors.TabBarSection.TapTabBarScrollToTop", fallback: "Tap tab bar scroll to top") + } + public enum TimelineRefreshingSection { + /// Automatically refresh timeline + public static let automaticallyRefreshTimeline = L10n.tr("Localizable", "Scene.Settings.Behaviors.TimelineRefreshingSection.AutomaticallyRefreshTimeline", fallback: "Automatically refresh timeline") + /// Refresh interval + public static let refreshInterval = L10n.tr("Localizable", "Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshInterval", fallback: "Refresh interval") + /// Reset to top + public static let resetToTop = L10n.tr("Localizable", "Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTop", fallback: "Reset to top") + /// Timeline Refreshing + public static let timelineRefreshing = L10n.tr("Localizable", "Scene.Settings.Behaviors.TimelineRefreshingSection.TimelineRefreshing", fallback: "Timeline Refreshing") + public enum RefreshIntervalOption { + /// 120 seconds + public static let _120Seconds = L10n.tr("Localizable", "Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.120Seconds", fallback: "120 seconds") + /// 300 seconds + public static let _300Seconds = L10n.tr("Localizable", "Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.300Seconds", fallback: "300 seconds") + /// 30 seconds + public static let _30Seconds = L10n.tr("Localizable", "Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.30Seconds", fallback: "30 seconds") + /// 60 seconds + public static let _60Seconds = L10n.tr("Localizable", "Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.60Seconds", fallback: "60 seconds") + } + public enum ResetToTopOption { + /// Double Tap + public static let doubleTap = L10n.tr("Localizable", "Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTopOption.DoubleTap", fallback: "Double Tap") + /// Single Tap + public static let singleTap = L10n.tr("Localizable", "Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTopOption.SingleTap", fallback: "Single Tap") + } } } public enum Display { /// Display - public static let title = L10n.tr("Localizable", "Scene.Settings.Display.Title") + public static let title = L10n.tr("Localizable", "Scene.Settings.Display.Title", fallback: "Display") /// Url previews - public static let urlPreview = L10n.tr("Localizable", "Scene.Settings.Display.UrlPreview") + public static let urlPreview = L10n.tr("Localizable", "Scene.Settings.Display.UrlPreview", fallback: "Url previews") public enum DateFormat { /// Absolute - public static let absolute = L10n.tr("Localizable", "Scene.Settings.Display.DateFormat.Absolute") + public static let absolute = L10n.tr("Localizable", "Scene.Settings.Display.DateFormat.Absolute", fallback: "Absolute") /// Relative - public static let relative = L10n.tr("Localizable", "Scene.Settings.Display.DateFormat.Relative") + public static let relative = L10n.tr("Localizable", "Scene.Settings.Display.DateFormat.Relative", fallback: "Relative") } public enum Media { /// Always - public static let always = L10n.tr("Localizable", "Scene.Settings.Display.Media.Always") + public static let always = L10n.tr("Localizable", "Scene.Settings.Display.Media.Always", fallback: "Always") /// Automatic - public static let automatic = L10n.tr("Localizable", "Scene.Settings.Display.Media.Automatic") + public static let automatic = L10n.tr("Localizable", "Scene.Settings.Display.Media.Automatic", fallback: "Automatic") /// Auto playback - public static let autoPlayback = L10n.tr("Localizable", "Scene.Settings.Display.Media.AutoPlayback") + public static let autoPlayback = L10n.tr("Localizable", "Scene.Settings.Display.Media.AutoPlayback", fallback: "Auto playback") /// Media previews - public static let mediaPreviews = L10n.tr("Localizable", "Scene.Settings.Display.Media.MediaPreviews") + public static let mediaPreviews = L10n.tr("Localizable", "Scene.Settings.Display.Media.MediaPreviews", fallback: "Media previews") /// Mute by default - public static let muteByDefault = L10n.tr("Localizable", "Scene.Settings.Display.Media.MuteByDefault") + public static let muteByDefault = L10n.tr("Localizable", "Scene.Settings.Display.Media.MuteByDefault", fallback: "Mute by default") /// Off - public static let off = L10n.tr("Localizable", "Scene.Settings.Display.Media.Off") + public static let off = L10n.tr("Localizable", "Scene.Settings.Display.Media.Off", fallback: "Off") } public enum Preview { /// Thanks for using @TwidereProject! - public static let thankForUsingTwidereX = L10n.tr("Localizable", "Scene.Settings.Display.Preview.ThankForUsingTwidereX") + public static let thankForUsingTwidereX = L10n.tr("Localizable", "Scene.Settings.Display.Preview.ThankForUsingTwidereX", fallback: "Thanks for using @TwidereProject!") } public enum SectionHeader { + /// Avatar + public static let avatar = L10n.tr("Localizable", "Scene.Settings.Display.SectionHeader.Avatar", fallback: "Avatar") /// Date Format - public static let dateFormat = L10n.tr("Localizable", "Scene.Settings.Display.SectionHeader.DateFormat") + public static let dateFormat = L10n.tr("Localizable", "Scene.Settings.Display.SectionHeader.DateFormat", fallback: "Date Format") /// Media - public static let media = L10n.tr("Localizable", "Scene.Settings.Display.SectionHeader.Media") + public static let media = L10n.tr("Localizable", "Scene.Settings.Display.SectionHeader.Media", fallback: "Media") /// Preview - public static let preview = L10n.tr("Localizable", "Scene.Settings.Display.SectionHeader.Preview") + public static let preview = L10n.tr("Localizable", "Scene.Settings.Display.SectionHeader.Preview", fallback: "Preview") /// Text - public static let text = L10n.tr("Localizable", "Scene.Settings.Display.SectionHeader.Text") + public static let text = L10n.tr("Localizable", "Scene.Settings.Display.SectionHeader.Text", fallback: "Text") } public enum Text { /// Avatar Style - public static let avatarStyle = L10n.tr("Localizable", "Scene.Settings.Display.Text.AvatarStyle") + public static let avatarStyle = L10n.tr("Localizable", "Scene.Settings.Display.Text.AvatarStyle", fallback: "Avatar Style") /// Circle - public static let circle = L10n.tr("Localizable", "Scene.Settings.Display.Text.Circle") + public static let circle = L10n.tr("Localizable", "Scene.Settings.Display.Text.Circle", fallback: "Circle") /// Rounded Square - public static let roundedSquare = L10n.tr("Localizable", "Scene.Settings.Display.Text.RoundedSquare") + public static let roundedSquare = L10n.tr("Localizable", "Scene.Settings.Display.Text.RoundedSquare", fallback: "Rounded Square") /// Use the system font size - public static let useTheSystemFontSize = L10n.tr("Localizable", "Scene.Settings.Display.Text.UseTheSystemFontSize") + public static let useTheSystemFontSize = L10n.tr("Localizable", "Scene.Settings.Display.Text.UseTheSystemFontSize", fallback: "Use the system font size") } } public enum Layout { /// Layout - public static let title = L10n.tr("Localizable", "Scene.Settings.Layout.Title") + public static let title = L10n.tr("Localizable", "Scene.Settings.Layout.Title", fallback: "Layout") public enum Actions { /// Drawer actions - public static let drawer = L10n.tr("Localizable", "Scene.Settings.Layout.Actions.Drawer") + public static let drawer = L10n.tr("Localizable", "Scene.Settings.Layout.Actions.Drawer", fallback: "Drawer actions") /// Tabbar actions - public static let tabbar = L10n.tr("Localizable", "Scene.Settings.Layout.Actions.Tabbar") + public static let tabbar = L10n.tr("Localizable", "Scene.Settings.Layout.Actions.Tabbar", fallback: "Tabbar actions") } public enum Desc { /// Choose and arrange up to 5 actions that will appear on the tabbar (The local and federal timelines will only be displayed in Mastodon.) - public static let content = L10n.tr("Localizable", "Scene.Settings.Layout.Desc.Content") + public static let content = L10n.tr("Localizable", "Scene.Settings.Layout.Desc.Content", fallback: "Choose and arrange up to 5 actions that will appear on the tabbar (The local and federal timelines will only be displayed in Mastodon.)") /// Custom Layout - public static let title = L10n.tr("Localizable", "Scene.Settings.Layout.Desc.Title") + public static let title = L10n.tr("Localizable", "Scene.Settings.Layout.Desc.Title", fallback: "Custom Layout") } } public enum Misc { /// Misc - public static let title = L10n.tr("Localizable", "Scene.Settings.Misc.Title") + public static let title = L10n.tr("Localizable", "Scene.Settings.Misc.Title", fallback: "Misc") public enum Nitter { /// Third-party Twitter data provider - public static let title = L10n.tr("Localizable", "Scene.Settings.Misc.Nitter.Title") + public static let title = L10n.tr("Localizable", "Scene.Settings.Misc.Nitter.Title", fallback: "Third-party Twitter data provider") public enum Dialog { public enum Information { /// Due to the limitation of Twitter API, some data might not be able to fetch from Twitter, you can use a third-party data provider to provide these data. Twidere does not take any responsibility for them. - public static let content = L10n.tr("Localizable", "Scene.Settings.Misc.Nitter.Dialog.Information.Content") + public static let content = L10n.tr("Localizable", "Scene.Settings.Misc.Nitter.Dialog.Information.Content", fallback: "Due to the limitation of Twitter API, some data might not be able to fetch from Twitter, you can use a third-party data provider to provide these data. Twidere does not take any responsibility for them.") /// Third party Twitter data provider - public static let title = L10n.tr("Localizable", "Scene.Settings.Misc.Nitter.Dialog.Information.Title") + public static let title = L10n.tr("Localizable", "Scene.Settings.Misc.Nitter.Dialog.Information.Title", fallback: "Third party Twitter data provider") } public enum Usage { /// - Twitter status threading - public static let content = L10n.tr("Localizable", "Scene.Settings.Misc.Nitter.Dialog.Usage.Content") + public static let content = L10n.tr("Localizable", "Scene.Settings.Misc.Nitter.Dialog.Usage.Content", fallback: "- Twitter status threading") /// Project URL - public static let projectButton = L10n.tr("Localizable", "Scene.Settings.Misc.Nitter.Dialog.Usage.ProjectButton") + public static let projectButton = L10n.tr("Localizable", "Scene.Settings.Misc.Nitter.Dialog.Usage.ProjectButton", fallback: "Project URL") /// Using Third-party data provider in - public static let title = L10n.tr("Localizable", "Scene.Settings.Misc.Nitter.Dialog.Usage.Title") + public static let title = L10n.tr("Localizable", "Scene.Settings.Misc.Nitter.Dialog.Usage.Title", fallback: "Using Third-party data provider in") } } public enum Input { /// Alternative Twitter front-end focused on privacy. - public static let description = L10n.tr("Localizable", "Scene.Settings.Misc.Nitter.Input.Description") + public static let description = L10n.tr("Localizable", "Scene.Settings.Misc.Nitter.Input.Description", fallback: "Alternative Twitter front-end focused on privacy.") /// Nitter instance URL is invalid, e.g. https://nitter.net - public static let invalid = L10n.tr("Localizable", "Scene.Settings.Misc.Nitter.Input.Invalid") + public static let invalid = L10n.tr("Localizable", "Scene.Settings.Misc.Nitter.Input.Invalid", fallback: "Nitter instance URL is invalid, e.g. https://nitter.net") /// Nitter Instance - public static let placeholder = L10n.tr("Localizable", "Scene.Settings.Misc.Nitter.Input.Placeholder") + public static let placeholder = L10n.tr("Localizable", "Scene.Settings.Misc.Nitter.Input.Placeholder", fallback: "Nitter Instance") /// Instance URL - public static let value = L10n.tr("Localizable", "Scene.Settings.Misc.Nitter.Input.Value") + public static let value = L10n.tr("Localizable", "Scene.Settings.Misc.Nitter.Input.Value", fallback: "Instance URL") } } public enum Proxy { /// Password - public static let password = L10n.tr("Localizable", "Scene.Settings.Misc.Proxy.Password") + public static let password = L10n.tr("Localizable", "Scene.Settings.Misc.Proxy.Password", fallback: "Password") /// Server - public static let server = L10n.tr("Localizable", "Scene.Settings.Misc.Proxy.Server") + public static let server = L10n.tr("Localizable", "Scene.Settings.Misc.Proxy.Server", fallback: "Server") /// Proxy settings - public static let title = L10n.tr("Localizable", "Scene.Settings.Misc.Proxy.Title") + public static let title = L10n.tr("Localizable", "Scene.Settings.Misc.Proxy.Title", fallback: "Proxy settings") /// Username - public static let username = L10n.tr("Localizable", "Scene.Settings.Misc.Proxy.Username") + public static let username = L10n.tr("Localizable", "Scene.Settings.Misc.Proxy.Username", fallback: "Username") public enum Enable { /// Use proxy for all network requests - public static let description = L10n.tr("Localizable", "Scene.Settings.Misc.Proxy.Enable.Description") + public static let description = L10n.tr("Localizable", "Scene.Settings.Misc.Proxy.Enable.Description", fallback: "Use proxy for all network requests") /// Proxy - public static let title = L10n.tr("Localizable", "Scene.Settings.Misc.Proxy.Enable.Title") + public static let title = L10n.tr("Localizable", "Scene.Settings.Misc.Proxy.Enable.Title", fallback: "Proxy") } public enum Port { /// Proxy server port must be numbers - public static let error = L10n.tr("Localizable", "Scene.Settings.Misc.Proxy.Port.Error") + public static let error = L10n.tr("Localizable", "Scene.Settings.Misc.Proxy.Port.Error", fallback: "Proxy server port must be numbers") /// Port - public static let title = L10n.tr("Localizable", "Scene.Settings.Misc.Proxy.Port.Title") + public static let title = L10n.tr("Localizable", "Scene.Settings.Misc.Proxy.Port.Title", fallback: "Port") } public enum `Type` { /// HTTP - public static let http = L10n.tr("Localizable", "Scene.Settings.Misc.Proxy.Type.Http") + public static let http = L10n.tr("Localizable", "Scene.Settings.Misc.Proxy.Type.Http", fallback: "HTTP") /// Reverse - public static let reverse = L10n.tr("Localizable", "Scene.Settings.Misc.Proxy.Type.Reverse") + public static let reverse = L10n.tr("Localizable", "Scene.Settings.Misc.Proxy.Type.Reverse", fallback: "Reverse") /// SOCKS - public static let socks = L10n.tr("Localizable", "Scene.Settings.Misc.Proxy.Type.Socks") + public static let socks = L10n.tr("Localizable", "Scene.Settings.Misc.Proxy.Type.Socks", fallback: "SOCKS") /// Proxy type - public static let title = L10n.tr("Localizable", "Scene.Settings.Misc.Proxy.Type.Title") + public static let title = L10n.tr("Localizable", "Scene.Settings.Misc.Proxy.Type.Title", fallback: "Proxy type") } } } public enum Notification { /// Accounts - public static let accounts = L10n.tr("Localizable", "Scene.Settings.Notification.Accounts") + public static let accounts = L10n.tr("Localizable", "Scene.Settings.Notification.Accounts", fallback: "Accounts") /// Show Notification - public static let notificationSwitch = L10n.tr("Localizable", "Scene.Settings.Notification.NotificationSwitch") + public static let notificationSwitch = L10n.tr("Localizable", "Scene.Settings.Notification.NotificationSwitch", fallback: "Show Notification") /// Push Notification - public static let pushNotification = L10n.tr("Localizable", "Scene.Settings.Notification.PushNotification") + public static let pushNotification = L10n.tr("Localizable", "Scene.Settings.Notification.PushNotification", fallback: "Push Notification") /// Notification - public static let title = L10n.tr("Localizable", "Scene.Settings.Notification.Title") + public static let title = L10n.tr("Localizable", "Scene.Settings.Notification.Title", fallback: "Notification") public enum Mastodon { /// Favorite - public static let favorite = L10n.tr("Localizable", "Scene.Settings.Notification.Mastodon.Favorite") + public static let favorite = L10n.tr("Localizable", "Scene.Settings.Notification.Mastodon.Favorite", fallback: "Favorite") /// Mention - public static let mention = L10n.tr("Localizable", "Scene.Settings.Notification.Mastodon.Mention") + public static let mention = L10n.tr("Localizable", "Scene.Settings.Notification.Mastodon.Mention", fallback: "Mention") /// New Follow - public static let newFollow = L10n.tr("Localizable", "Scene.Settings.Notification.Mastodon.NewFollow") + public static let newFollow = L10n.tr("Localizable", "Scene.Settings.Notification.Mastodon.NewFollow", fallback: "New Follow") /// poll - public static let poll = L10n.tr("Localizable", "Scene.Settings.Notification.Mastodon.Poll") + public static let poll = L10n.tr("Localizable", "Scene.Settings.Notification.Mastodon.Poll", fallback: "poll") /// Reblog - public static let reblog = L10n.tr("Localizable", "Scene.Settings.Notification.Mastodon.Reblog") + public static let reblog = L10n.tr("Localizable", "Scene.Settings.Notification.Mastodon.Reblog", fallback: "Reblog") + } + } + public enum PrivacyAndSafety { + /// Always show sensitive media + public static let alwaysShowSensitiveMedia = L10n.tr("Localizable", "Scene.Settings.PrivacyAndSafety.AlwaysShowSensitiveMedia", fallback: "Always show sensitive media") + /// Privacy and safety + public static let title = L10n.tr("Localizable", "Scene.Settings.PrivacyAndSafety.Title", fallback: "Privacy and safety") + public enum SectionHeader { + /// Mute and Block + public static let muteAndBlock = L10n.tr("Localizable", "Scene.Settings.PrivacyAndSafety.SectionHeader.MuteAndBlock", fallback: "Mute and Block") + /// Sensitive Info + public static let sensitive = L10n.tr("Localizable", "Scene.Settings.PrivacyAndSafety.SectionHeader.Sensitive", fallback: "Sensitive Info") } } public enum SectionHeader { /// About - public static let about = L10n.tr("Localizable", "Scene.Settings.SectionHeader.About") + public static let about = L10n.tr("Localizable", "Scene.Settings.SectionHeader.About", fallback: "About") /// Account - public static let account = L10n.tr("Localizable", "Scene.Settings.SectionHeader.Account") + public static let account = L10n.tr("Localizable", "Scene.Settings.SectionHeader.Account", fallback: "Account") /// General - public static let general = L10n.tr("Localizable", "Scene.Settings.SectionHeader.General") + public static let general = L10n.tr("Localizable", "Scene.Settings.SectionHeader.General", fallback: "General") } public enum Storage { /// Storage - public static let title = L10n.tr("Localizable", "Scene.Settings.Storage.Title") + public static let title = L10n.tr("Localizable", "Scene.Settings.Storage.Title", fallback: "Storage") public enum All { /// Delete all Twidere X cache. Your account credentials will not be lost. - public static let subTitle = L10n.tr("Localizable", "Scene.Settings.Storage.All.SubTitle") + public static let subTitle = L10n.tr("Localizable", "Scene.Settings.Storage.All.SubTitle", fallback: "Delete all Twidere X cache. Your account credentials will not be lost.") /// Clear all cache - public static let title = L10n.tr("Localizable", "Scene.Settings.Storage.All.Title") + public static let title = L10n.tr("Localizable", "Scene.Settings.Storage.All.Title", fallback: "Clear all cache") } public enum Media { /// Clear stored media cache. - public static let subTitle = L10n.tr("Localizable", "Scene.Settings.Storage.Media.SubTitle") + public static let subTitle = L10n.tr("Localizable", "Scene.Settings.Storage.Media.SubTitle", fallback: "Clear stored media cache.") /// Clear media cache - public static let title = L10n.tr("Localizable", "Scene.Settings.Storage.Media.Title") + public static let title = L10n.tr("Localizable", "Scene.Settings.Storage.Media.Title", fallback: "Clear media cache") } public enum Search { /// Clear search history - public static let title = L10n.tr("Localizable", "Scene.Settings.Storage.Search.Title") + public static let title = L10n.tr("Localizable", "Scene.Settings.Storage.Search.Title", fallback: "Clear search history") } } } public enum SignIn { /// Hello! /// Sign in to Get Started. - public static let helloSignInToGetStarted = L10n.tr("Localizable", "Scene.SignIn.HelloSignInToGetStarted") + public static let helloSignInToGetStarted = L10n.tr("Localizable", "Scene.SignIn.HelloSignInToGetStarted", fallback: "Hello!\nSign in to Get Started.") /// Sign in with Mastodon - public static let signInWithMastodon = L10n.tr("Localizable", "Scene.SignIn.SignInWithMastodon") + public static let signInWithMastodon = L10n.tr("Localizable", "Scene.SignIn.SignInWithMastodon", fallback: "Sign in with Mastodon") /// Sign in with Twitter - public static let signInWithTwitter = L10n.tr("Localizable", "Scene.SignIn.SignInWithTwitter") + public static let signInWithTwitter = L10n.tr("Localizable", "Scene.SignIn.SignInWithTwitter", fallback: "Sign in with Twitter") public enum TwitterOptions { /// Sign in with Custom Twitter Key - public static let signInWithCustomTwitterKey = L10n.tr("Localizable", "Scene.SignIn.TwitterOptions.SignInWithCustomTwitterKey") + public static let signInWithCustomTwitterKey = L10n.tr("Localizable", "Scene.SignIn.TwitterOptions.SignInWithCustomTwitterKey", fallback: "Sign in with Custom Twitter Key") /// Twitter API v2 access is required. - public static let twitterApiV2AccessIsRequired = L10n.tr("Localizable", "Scene.SignIn.TwitterOptions.TwitterApiV2AccessIsRequired") + public static let twitterApiV2AccessIsRequired = L10n.tr("Localizable", "Scene.SignIn.TwitterOptions.TwitterApiV2AccessIsRequired", fallback: "Twitter API v2 access is required.") } } public enum Status { /// Tweet - public static let title = L10n.tr("Localizable", "Scene.Status.Title") + public static let title = L10n.tr("Localizable", "Scene.Status.Title", fallback: "Tweet") /// Toot - public static let titleMastodon = L10n.tr("Localizable", "Scene.Status.TitleMastodon") + public static let titleMastodon = L10n.tr("Localizable", "Scene.Status.TitleMastodon", fallback: "Toot") public enum Like { /// %d Likes public static func multiple(_ p1: Int) -> String { - return L10n.tr("Localizable", "Scene.Status.Like.Multiple", p1) + return L10n.tr("Localizable", "Scene.Status.Like.Multiple", p1, fallback: "%d Likes") } /// 1 Like - public static let single = L10n.tr("Localizable", "Scene.Status.Like.Single") + public static let single = L10n.tr("Localizable", "Scene.Status.Like.Single", fallback: "1 Like") } public enum Quote { /// %d Quotes public static func mutiple(_ p1: Int) -> String { - return L10n.tr("Localizable", "Scene.Status.Quote.Mutiple", p1) + return L10n.tr("Localizable", "Scene.Status.Quote.Mutiple", p1, fallback: "%d Quotes") } /// 1 Quote - public static let single = L10n.tr("Localizable", "Scene.Status.Quote.Single") + public static let single = L10n.tr("Localizable", "Scene.Status.Quote.Single", fallback: "1 Quote") } public enum Reply { /// %d Replies public static func mutiple(_ p1: Int) -> String { - return L10n.tr("Localizable", "Scene.Status.Reply.Mutiple", p1) + return L10n.tr("Localizable", "Scene.Status.Reply.Mutiple", p1, fallback: "%d Replies") } /// 1 Reply - public static let single = L10n.tr("Localizable", "Scene.Status.Reply.Single") + public static let single = L10n.tr("Localizable", "Scene.Status.Reply.Single", fallback: "1 Reply") } public enum Retweet { /// %d Retweets public static func mutiple(_ p1: Int) -> String { - return L10n.tr("Localizable", "Scene.Status.Retweet.Mutiple", p1) + return L10n.tr("Localizable", "Scene.Status.Retweet.Mutiple", p1, fallback: "%d Retweets") } /// 1 Retweet - public static let single = L10n.tr("Localizable", "Scene.Status.Retweet.Single") + public static let single = L10n.tr("Localizable", "Scene.Status.Retweet.Single", fallback: "1 Retweet") } } public enum Timeline { /// Timeline - public static let title = L10n.tr("Localizable", "Scene.Timeline.Title") + public static let title = L10n.tr("Localizable", "Scene.Timeline.Title", fallback: "Timeline") } public enum Trends { /// %d people talking public static func accounts(_ p1: Int) -> String { - return L10n.tr("Localizable", "Scene.Trends.Accounts", p1) + return L10n.tr("Localizable", "Scene.Trends.Accounts", p1, fallback: "%d people talking") } /// Trending Now - public static let now = L10n.tr("Localizable", "Scene.Trends.Now") + public static let now = L10n.tr("Localizable", "Scene.Trends.Now", fallback: "Trending Now") /// Trends - public static let title = L10n.tr("Localizable", "Scene.Trends.Title") + public static let title = L10n.tr("Localizable", "Scene.Trends.Title", fallback: "Trends") /// Trends Location - public static let trendsLocation = L10n.tr("Localizable", "Scene.Trends.TrendsLocation") + public static let trendsLocation = L10n.tr("Localizable", "Scene.Trends.TrendsLocation", fallback: "Trends Location") /// Trends - Worldwide - public static let worldWide = L10n.tr("Localizable", "Scene.Trends.WorldWide") + public static let worldWide = L10n.tr("Localizable", "Scene.Trends.WorldWide", fallback: "Trends - Worldwide") /// Worldwide - public static let worldWideWithoutPrefix = L10n.tr("Localizable", "Scene.Trends.WorldWideWithoutPrefix") + public static let worldWideWithoutPrefix = L10n.tr("Localizable", "Scene.Trends.WorldWideWithoutPrefix", fallback: "Worldwide") } } - public enum Count { /// Plural format key: "Input limit remains %#@character_count@" public static func inputLimitRemains(_ p1: Int) -> String { - return L10n.tr("Localizable", "count.input_limit_remains", p1) + return L10n.tr("Localizable", "count.input_limit_remains", p1, fallback: "Plural format key: \"Input limit remains %#@character_count@\"") } /// Plural format key: "%#@like_count@" public static func like(_ p1: Int) -> String { - return L10n.tr("Localizable", "count.like", p1) + return L10n.tr("Localizable", "count.like", p1, fallback: "Plural format key: \"%#@like_count@\"") } /// Plural format key: "%#@count_media@" public static func media(_ p1: Int) -> String { - return L10n.tr("Localizable", "count.media", p1) + return L10n.tr("Localizable", "count.media", p1, fallback: "Plural format key: \"%#@count_media@\"") } /// Plural format key: "%#@count_notification@" public static func notification(_ p1: Int) -> String { - return L10n.tr("Localizable", "count.notification", p1) + return L10n.tr("Localizable", "count.notification", p1, fallback: "Plural format key: \"%#@count_notification@\"") } /// Plural format key: "%#@count_people@" public static func people(_ p1: Int) -> String { - return L10n.tr("Localizable", "count.people", p1) + return L10n.tr("Localizable", "count.people", p1, fallback: "Plural format key: \"%#@count_people@\"") } /// Plural format key: "%#@post_count@" public static func post(_ p1: Int) -> String { - return L10n.tr("Localizable", "count.post", p1) + return L10n.tr("Localizable", "count.post", p1, fallback: "Plural format key: \"%#@post_count@\"") } /// Plural format key: "%#@quote_count@" public static func quote(_ p1: Int) -> String { - return L10n.tr("Localizable", "count.quote", p1) + return L10n.tr("Localizable", "count.quote", p1, fallback: "Plural format key: \"%#@quote_count@\"") } /// Plural format key: "%#@reblog_count@" public static func reblog(_ p1: Int) -> String { - return L10n.tr("Localizable", "count.reblog", p1) + return L10n.tr("Localizable", "count.reblog", p1, fallback: "Plural format key: \"%#@reblog_count@\"") } /// Plural format key: "%#@reply_count@" public static func reply(_ p1: Int) -> String { - return L10n.tr("Localizable", "count.reply", p1) + return L10n.tr("Localizable", "count.reply", p1, fallback: "Plural format key: \"%#@reply_count@\"") } /// Plural format key: "%#@retweet_count@" public static func retweet(_ p1: Int) -> String { - return L10n.tr("Localizable", "count.retweet", p1) + return L10n.tr("Localizable", "count.retweet", p1, fallback: "Plural format key: \"%#@retweet_count@\"") } /// Plural format key: "%#@count_vote@" public static func vote(_ p1: Int) -> String { - return L10n.tr("Localizable", "count.vote", p1) + return L10n.tr("Localizable", "count.vote", p1, fallback: "Plural format key: \"%#@count_vote@\"") } public enum MetricFormatted { /// Plural format key: "%@ %#@post_count@" public static func post(_ p1: Any, _ p2: Int) -> String { - return L10n.tr("Localizable", "count.metric_formatted.post", String(describing: p1), p2) + return L10n.tr("Localizable", "count.metric_formatted.post", String(describing: p1), p2, fallback: "Plural format key: \"%@ %#@post_count@\"") } } public enum People { /// Plural format key: "%#@count_people_talking@" public static func talking(_ p1: Int) -> String { - return L10n.tr("Localizable", "count.people.talking", p1) + return L10n.tr("Localizable", "count.people.talking", p1, fallback: "Plural format key: \"%#@count_people_talking@\"") } } } - public enum Date { public enum Day { /// Plural format key: "%#@count_day_left@" public static func `left`(_ p1: Int) -> String { - return L10n.tr("Localizable", "date.day.left", p1) + return L10n.tr("Localizable", "date.day.left", p1, fallback: "Plural format key: \"%#@count_day_left@\"") } } public enum Hour { /// Plural format key: "%#@count_hour_left@" public static func `left`(_ p1: Int) -> String { - return L10n.tr("Localizable", "date.hour.left", p1) + return L10n.tr("Localizable", "date.hour.left", p1, fallback: "Plural format key: \"%#@count_hour_left@\"") } } public enum Minute { /// Plural format key: "%#@count_minute_left@" public static func `left`(_ p1: Int) -> String { - return L10n.tr("Localizable", "date.minute.left", p1) + return L10n.tr("Localizable", "date.minute.left", p1, fallback: "Plural format key: \"%#@count_minute_left@\"") } } public enum Month { /// Plural format key: "%#@count_month_left@" public static func `left`(_ p1: Int) -> String { - return L10n.tr("Localizable", "date.month.left", p1) + return L10n.tr("Localizable", "date.month.left", p1, fallback: "Plural format key: \"%#@count_month_left@\"") } } public enum Second { /// Plural format key: "%#@count_second_left@" public static func `left`(_ p1: Int) -> String { - return L10n.tr("Localizable", "date.second.left", p1) + return L10n.tr("Localizable", "date.second.left", p1, fallback: "Plural format key: \"%#@count_second_left@\"") } } public enum Year { /// Plural format key: "%#@count_year_left@" public static func `left`(_ p1: Int) -> String { - return L10n.tr("Localizable", "date.year.left", p1) + return L10n.tr("Localizable", "date.year.left", p1, fallback: "Plural format key: \"%#@count_year_left@\"") } } } @@ -1722,8 +1831,8 @@ public enum L10n { // MARK: - Implementation Details extension L10n { - private static func tr(_ table: String, _ key: String, _ args: CVarArg...) -> String { - let format = Bundle.module.localizedString(forKey: key, value: nil, table: table) + private static func tr(_ table: String, _ key: String, _ args: CVarArg..., fallback value: String) -> String { + let format = Bundle.module.localizedString(forKey: key, value: value, table: table) return String(format: format, locale: Locale.current, arguments: args) } } diff --git a/TwidereSDK/Sources/TwidereLocalization/Resources/ar.lproj/Localizable.strings b/TwidereSDK/Sources/TwidereLocalization/Resources/ar.lproj/Localizable.strings index 1d635c1c..0f37541b 100644 --- a/TwidereSDK/Sources/TwidereLocalization/Resources/ar.lproj/Localizable.strings +++ b/TwidereSDK/Sources/TwidereLocalization/Resources/ar.lproj/Localizable.strings @@ -128,11 +128,13 @@ "Common.Alerts.PhotoSaveFail.Title" = "تعذر حفظ الصورة"; "Common.Alerts.PhotoSaved.Title" = "حُفظت الصورة"; "Common.Alerts.PostFailInvalidPoll.Message" = "Poll has empty field. Please fulfill the field then try again"; -"Common.Alerts.PostFailInvalidPoll.Title" = "Failed to Publish"; +"Common.Alerts.PostFailInvalidPoll.Title" = "تعذر النشر"; "Common.Alerts.RateLimitExceeded.Message" = "وصلت إلى حد استخدام واجهة برمجة تطبيقات تويتر"; "Common.Alerts.RateLimitExceeded.Title" = "اجتزت الحد المسموح به"; "Common.Alerts.ReportAndBlockUserSuccess.Title" = "أُبلغ عن %@ وحجب بسبب الازعاج"; "Common.Alerts.ReportUserSuccess.Title" = "أُبلغ عن %@ بسبب الازعاج"; +"Common.Alerts.RequestThrottle.Message" = "Operation too frequent. Please try again later"; +"Common.Alerts.RequestThrottle.Title" = "Request Throttle"; "Common.Alerts.SignOutUserConfirm.Message" = "هل تريد الخروج؟"; "Common.Alerts.SignOutUserConfirm.Title" = "خروج"; "Common.Alerts.TooManyRequests.Title" = "طلبات كثيرة"; @@ -158,7 +160,7 @@ "Common.Controls.Actions.Add" = "أضف"; "Common.Controls.Actions.Browse" = "تصفح"; "Common.Controls.Actions.Cancel" = "ألغِ"; -"Common.Controls.Actions.Clear" = "Clear"; +"Common.Controls.Actions.Clear" = "مسح"; "Common.Controls.Actions.Confirm" = "أكِّد"; "Common.Controls.Actions.Copy" = "انسخ"; "Common.Controls.Actions.Delete" = "حذف"; @@ -179,6 +181,8 @@ "Common.Controls.Actions.SignOut" = "خروج"; "Common.Controls.Actions.TakePhoto" = "التقط صورة"; "Common.Controls.Actions.Yes" = "نعم"; +"Common.Controls.EmptyState.NoResults" = "لا نتائج"; +"Common.Controls.EmptyState.UnableToAccess" = "Unable to access"; "Common.Controls.Friendship.Actions.Block" = "احجب"; "Common.Controls.Friendship.Actions.Blocked" = "محجوب"; "Common.Controls.Friendship.Actions.Follow" = "تابعه"; @@ -192,8 +196,8 @@ "Common.Controls.Friendship.Actions.Unfollow" = "الغ متابعته"; "Common.Controls.Friendship.Actions.Unmute" = "ألغ الكتم"; "Common.Controls.Friendship.BlockUser" = "احجب %@"; -"Common.Controls.Friendship.DoYouWantToReportAnd BlockUser" = "Do you want to report and block %@"; -"Common.Controls.Friendship.DoYouWantToReportUser" = "Do you want to report %@"; +"Common.Controls.Friendship.DoYouWantToReportAndBlockUser" = "Do you want to report and block %@"; +"Common.Controls.Friendship.DoYouWantToReportUser" = "هل تريد أن تبلغ عن %@"; "Common.Controls.Friendship.Follower" = "متابِع"; "Common.Controls.Friendship.Followers" = "متابِعون"; "Common.Controls.Friendship.FollowsYou" = "يتابعك"; @@ -210,14 +214,20 @@ "Common.Controls.Status.Actions.CopyLink" = "انسخ الرابط"; "Common.Controls.Status.Actions.CopyText" = "انسخ النص"; "Common.Controls.Status.Actions.DeleteTweet" = "احذف التغريدة"; +"Common.Controls.Status.Actions.Like" = "أعجبني"; "Common.Controls.Status.Actions.PinOnProfile" = "ثبته في اللاحة"; "Common.Controls.Status.Actions.Quote" = "اقتبس"; +"Common.Controls.Status.Actions.Reply" = "رد"; +"Common.Controls.Status.Actions.Repost" = "Repost"; "Common.Controls.Status.Actions.Retweet" = "أعد تغريده"; "Common.Controls.Status.Actions.SaveMedia" = "احفظ الوسيط"; "Common.Controls.Status.Actions.Share" = "شاركه"; "Common.Controls.Status.Actions.ShareContent" = "شارك المحتوى"; "Common.Controls.Status.Actions.ShareLink" = "شارك الرابط"; "Common.Controls.Status.Actions.Translate" = "ترجمه"; +"Common.Controls.Status.Actions.UndoBoost" = "Undo Boost"; +"Common.Controls.Status.Actions.UndoRepost" = "Undo Repost"; +"Common.Controls.Status.Actions.UndoRetweet" = "Undo Retweet"; "Common.Controls.Status.Actions.UnpinFromProfile" = "ألغ تثبيتها من اللاحة"; "Common.Controls.Status.Actions.Vote" = "صوّت"; "Common.Controls.Status.Media" = "الوسائط"; @@ -226,8 +236,8 @@ "Common.Controls.Status.Poll.TotalPerson" = "%@ شخص"; "Common.Controls.Status.Poll.TotalVote" = "%@ صوت"; "Common.Controls.Status.Poll.TotalVotes" = "%@ صوت"; -"Common.Controls.Status.ReplySettings.PeopleUserFollowsOrMentionedCanReply" = "People %@ follows or mentioned can reply."; -"Common.Controls.Status.ReplySettings.PeopleUserMentionedCanReply" = "People %@ mentioned can reply."; +"Common.Controls.Status.ReplySettings.PeopleUserFollowsOrMentionedCanReply" = "يمكن للأشخاص المتابعين أو المشار إليهم %@ الرد."; +"Common.Controls.Status.ReplySettings.PeopleUserMentionedCanReply" = "يمكن للأشخاص المشار إليهم %@ الرد."; "Common.Controls.Status.Thread.Show" = "اظهر هذا النقاش"; "Common.Controls.Status.UserBoosted" = "رقى %@"; "Common.Controls.Status.UserRetweeted" = "%@ أعاد التغريد"; @@ -256,10 +266,10 @@ "Common.Notification.Favourite" = "أُعجِب %@ بتبويقك"; "Common.Notification.Follow" = "تابعك %@"; "Common.Notification.FollowRequest" = "%@ يطلب متابعتك"; -"Common.Notification.FollowRequestAction.Approve" = "Approve"; -"Common.Notification.FollowRequestAction.Deny" = "Deny"; -"Common.Notification.FollowRequestResponse.FollowRequestApproved" = "Follow Request Approved"; -"Common.Notification.FollowRequestResponse.FollowRequestDenied" = "Follow Request Denied"; +"Common.Notification.FollowRequestAction.Approve" = "موافقة"; +"Common.Notification.FollowRequestAction.Deny" = "رفض"; +"Common.Notification.FollowRequestResponse.FollowRequestApproved" = "الموافقة على طلب المتابعة"; +"Common.Notification.FollowRequestResponse.FollowRequestDenied" = "رفض طلب المتابعة"; "Common.Notification.Mentions" = "ذكرك %@"; "Common.Notification.Messages.Content" = "ارسل %@ لك رسالة"; "Common.Notification.Messages.Title" = "رسالة مباشرة جديدة"; @@ -274,20 +284,25 @@ "Common.NotificationChannel.ContentMessages.Name" = "الرسائل"; "Scene.Authentication.Title" = "المصادقة"; "Scene.Bookmark.Title" = "العلامات"; +"Scene.Column.Actions.CloseColumn" = "Close column"; +"Scene.Column.Actions.MoveLeft" = "Move left"; +"Scene.Column.Actions.MoveRight" = "Move right"; +"Scene.Column.Actions.OpenInNewColumn" = "Open in new column"; +"Scene.Column.Title" = "New Column"; "Scene.Compose.And" = "، "; "Scene.Compose.CwPlaceholder" = "اكتب التحذير"; "Scene.Compose.LastEnd" = " و "; "Scene.Compose.Media.Caption.Add" = "Add Caption"; -"Scene.Compose.Media.Caption.AddADescriptionForThisImage" = "Add a description for this image"; +"Scene.Compose.Media.Caption.AddADescriptionForThisImage" = "اضف وصف لهذه الصورة"; "Scene.Compose.Media.Caption.Remove" = "Remove Caption"; "Scene.Compose.Media.Caption.Update" = "Update Caption"; "Scene.Compose.Media.Preview" = "معاينة"; "Scene.Compose.Media.Remove" = "أزِل"; "Scene.Compose.OthersInThisConversation" = "آخرون في هذه المحادثة:"; "Scene.Compose.Placeholder" = "ماذا يحدث ؟"; -"Scene.Compose.ReplySettings.EveryoneCanReply" = "Everyone can peply"; -"Scene.Compose.ReplySettings.OnlyPeopleYouMentionCanReply" = "Only people you mention can reply"; -"Scene.Compose.ReplySettings.PeopleYouFollowCanReply" = "People you follow can reply"; +"Scene.Compose.ReplySettings.EveryoneCanReply" = "الجميع يمكنهم الرد"; +"Scene.Compose.ReplySettings.OnlyPeopleYouMentionCanReply" = "فقط الأشخاص الذين أشرت إليهم يمكنهم الرد"; +"Scene.Compose.ReplySettings.PeopleYouFollowCanReply" = "الأشخاص الذين تتابعهم يمكنهم الرد"; "Scene.Compose.ReplyTo" = "رد على …"; "Scene.Compose.ReplyingTo" = "رد على"; "Scene.Compose.SaveDraft.Action" = "احفظ المسودة"; @@ -314,6 +329,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" = "المسودات"; @@ -322,6 +338,11 @@ "Scene.Federated.Title" = "الشبكة الموحدة"; "Scene.Followers.Title" = "المتابِعون"; "Scene.Following.Title" = "المتابَعون"; +"Scene.History.Clear" = "مسح"; +"Scene.History.Scope.Toot" = "تبويقة"; +"Scene.History.Scope.Tweet" = "تغريدة"; +"Scene.History.Scope.User" = "المستخدم"; +"Scene.History.Title" = "التأريخ"; "Scene.Likes.Title" = "الإعجابات"; "Scene.Listed.Title" = "مدرج"; "Scene.Lists.Icons.Create" = "أنشئ قائمة"; @@ -360,6 +381,7 @@ "Scene.ListsUsers.MenuActions.Remove" = "أزِل"; "Scene.Local.Title" = "محلي"; "Scene.ManageAccounts.DeleteAccount" = "احذف الحساب"; +"Scene.ManageAccounts.OpenInNewWindow" = "Open in new window"; "Scene.ManageAccounts.Title" = "الحسابات"; "Scene.Mentions.Title" = "الذِكر"; "Scene.Messages.Action.CopyText" = "انسخ نص الرسالة"; @@ -398,9 +420,8 @@ "Scene.Settings.About.Logo.BackgroundShadow" = "تظليل شعار الخلفية لصفحة 'حول'"; "Scene.Settings.About.Title" = "حول"; "Scene.Settings.About.Version" = "النسخة %@"; -"Scene.Settings.Account.AccountSettings" = "Account Settings"; "Scene.Settings.Account.BlockedPeople" = "Blocked People"; -"Scene.Settings.Account.MuteAndBlock" = "Mute and Block"; +"Scene.Settings.Account.MuteAndBlock" = "كتم و حظر"; "Scene.Settings.Account.MutedPeople" = "Muted People"; "Scene.Settings.Account.Title" = "الحساب"; "Scene.Settings.Appearance.AmoledOptimizedMode" = "الوضع المحسّن لـ AMOLED"; @@ -425,6 +446,22 @@ "Scene.Settings.Appearance.Translation.Off" = "إيقاف"; "Scene.Settings.Appearance.Translation.Service" = "الخدمة"; "Scene.Settings.Appearance.Translation.TranslateButton" = "زر الترجمة"; +"Scene.Settings.Behaviors.HistorySection.EnableHistoryRecord" = "تمكين سجل التاريخ"; +"Scene.Settings.Behaviors.HistorySection.History" = "التأريخ"; +"Scene.Settings.Behaviors.TabBarSection.ShowTabBarLabels" = "عرض تسميات شريط التبويب"; +"Scene.Settings.Behaviors.TabBarSection.TabBar" = "شريط التبويب"; +"Scene.Settings.Behaviors.TabBarSection.TapTabBarScrollToTop" = "انقر على شريط التبويب لتمرير إلى الأعلى"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.AutomaticallyRefreshTimeline" = "تحديث الخط الزمني تلقائياً"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshInterval" = "الفاصل الزمني للتحديث"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.120Seconds" = "120 ثانية"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.300Seconds" = "300 ثانية"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.30Seconds" = "30 ثانية"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.60Seconds" = "60 ثانية"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTop" = "إعادة التعيين إلى الأعلى"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTopOption.DoubleTap" = "نقر مزدوج"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTopOption.SingleTap" = "نقرة واحدة"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.TimelineRefreshing" = "تحديث الخط الزمني"; +"Scene.Settings.Behaviors.Title" = "السلوكيات"; "Scene.Settings.Display.DateFormat.Absolute" = "مُطلق"; "Scene.Settings.Display.DateFormat.Relative" = "نسبي"; "Scene.Settings.Display.Media.Always" = "دائماً"; @@ -434,6 +471,7 @@ "Scene.Settings.Display.Media.MuteByDefault" = "مكتوم افتراضيًا"; "Scene.Settings.Display.Media.Off" = "إيقاف"; "Scene.Settings.Display.Preview.ThankForUsingTwidereX" = "شكرا على استخدام @TwidereProject!"; +"Scene.Settings.Display.SectionHeader.Avatar" = "الصورة الرمزية"; "Scene.Settings.Display.SectionHeader.DateFormat" = "صيغة التاريخ"; "Scene.Settings.Display.SectionHeader.Media" = "الوسائط"; "Scene.Settings.Display.SectionHeader.Preview" = "معاينة"; @@ -473,14 +511,18 @@ "Scene.Settings.Misc.Proxy.Username" = "اسم المستخدم"; "Scene.Settings.Misc.Title" = "متفرقات"; "Scene.Settings.Notification.Accounts" = "الحسابات"; -"Scene.Settings.Notification.Mastodon.Favorite" = "Favorite"; -"Scene.Settings.Notification.Mastodon.Mention" = "Mention"; -"Scene.Settings.Notification.Mastodon.NewFollow" = "New Follow"; -"Scene.Settings.Notification.Mastodon.Poll" = "poll"; -"Scene.Settings.Notification.Mastodon.Reblog" = "Reblog"; +"Scene.Settings.Notification.Mastodon.Favorite" = "المفضلة"; +"Scene.Settings.Notification.Mastodon.Mention" = "إشارة"; +"Scene.Settings.Notification.Mastodon.NewFollow" = "متابع جديد"; +"Scene.Settings.Notification.Mastodon.Poll" = "تصويت"; +"Scene.Settings.Notification.Mastodon.Reblog" = "معاد تدوينه"; "Scene.Settings.Notification.NotificationSwitch" = "أظهر الإشعارات"; -"Scene.Settings.Notification.PushNotification" = "Push Notification"; +"Scene.Settings.Notification.PushNotification" = "إرسال الإشعارات"; "Scene.Settings.Notification.Title" = "التنبيهات"; +"Scene.Settings.PrivacyAndSafety.AlwaysShowSensitiveMedia" = "Always show sensitive media"; +"Scene.Settings.PrivacyAndSafety.SectionHeader.MuteAndBlock" = "كتم و حظر"; +"Scene.Settings.PrivacyAndSafety.SectionHeader.Sensitive" = "Sensitive Info"; +"Scene.Settings.PrivacyAndSafety.Title" = "Privacy and safety"; "Scene.Settings.SectionHeader.About" = "حول"; "Scene.Settings.SectionHeader.Account" = "الحساب"; "Scene.Settings.SectionHeader.General" = "عامّ"; @@ -513,4 +555,4 @@ "Scene.Trends.Title" = "الشائع"; "Scene.Trends.TrendsLocation" = "Trends Location"; "Scene.Trends.WorldWide" = "الشائع - عالميا"; -"Scene.Trends.WorldWideWithoutPrefix" = "Worldwide"; \ No newline at end of file +"Scene.Trends.WorldWideWithoutPrefix" = "عالمي"; \ No newline at end of file diff --git a/TwidereSDK/Sources/TwidereLocalization/Resources/ca.lproj/Localizable.strings b/TwidereSDK/Sources/TwidereLocalization/Resources/ca.lproj/Localizable.strings index 58d211d5..3076b00c 100644 --- a/TwidereSDK/Sources/TwidereLocalization/Resources/ca.lproj/Localizable.strings +++ b/TwidereSDK/Sources/TwidereLocalization/Resources/ca.lproj/Localizable.strings @@ -8,21 +8,21 @@ "Accessibility.Common.Logo.Twitter" = "Logo de Twitter"; "Accessibility.Common.More" = "Més"; "Accessibility.Common.NetworkImage" = "Imatge de xarxa"; -"Accessibility.Common.Status.Actions.HideContent" = "Hide content"; -"Accessibility.Common.Status.Actions.HideMedia" = "Hide media"; +"Accessibility.Common.Status.Actions.HideContent" = "Amaga el contingut"; +"Accessibility.Common.Status.Actions.HideMedia" = "Amaga multimèdia"; "Accessibility.Common.Status.Actions.Like" = "M'agrada"; "Accessibility.Common.Status.Actions.Menu" = "Menú"; "Accessibility.Common.Status.Actions.Reply" = "Resposta"; "Accessibility.Common.Status.Actions.Retweet" = "Repiular"; -"Accessibility.Common.Status.Actions.RevealContent" = "Reveal content"; -"Accessibility.Common.Status.Actions.RevealMedia" = "Reveal media"; -"Accessibility.Common.Status.AuthorAvatar" = "Author avatar"; -"Accessibility.Common.Status.Boosted" = "Boosted"; -"Accessibility.Common.Status.ContentWarning" = "Content Warning"; -"Accessibility.Common.Status.Liked" = "Liked"; +"Accessibility.Common.Status.Actions.RevealContent" = "Revela el contingut"; +"Accessibility.Common.Status.Actions.RevealMedia" = "Revela multimèdia"; +"Accessibility.Common.Status.AuthorAvatar" = "Avatar de l'autor"; +"Accessibility.Common.Status.Boosted" = "Impulsat"; +"Accessibility.Common.Status.ContentWarning" = "Avís de contingut"; +"Accessibility.Common.Status.Liked" = "Agradat"; "Accessibility.Common.Status.Location" = "Ubicació"; "Accessibility.Common.Status.Media" = "Multimèdia"; -"Accessibility.Common.Status.PollOptionOrdinalPrefix" = "Poll option"; +"Accessibility.Common.Status.PollOptionOrdinalPrefix" = "Opció de votació"; "Accessibility.Common.Status.Retweeted" = "Repiulades"; "Accessibility.Common.Video.Play" = "Reprodueix el vídeo"; "Accessibility.Scene.Compose.AddMention" = "Afegeix menció"; @@ -30,54 +30,54 @@ "Accessibility.Scene.Compose.Image" = "Afegeix imatge"; "Accessibility.Scene.Compose.Location.Disable" = "Desactivar la ubicació"; "Accessibility.Scene.Compose.Location.Enable" = "Habilita la ubicació"; -"Accessibility.Scene.Compose.MediaInsert.Camera" = "Take Photo"; -"Accessibility.Scene.Compose.MediaInsert.Gif" = "Add GIF"; -"Accessibility.Scene.Compose.MediaInsert.Library" = "Browse Library"; -"Accessibility.Scene.Compose.MediaInsert.RecordVideo" = "Record Video"; +"Accessibility.Scene.Compose.MediaInsert.Camera" = "Fes una foto"; +"Accessibility.Scene.Compose.MediaInsert.Gif" = "Afegiu un GIF"; +"Accessibility.Scene.Compose.MediaInsert.Library" = "Navegueu per la biblioteca"; +"Accessibility.Scene.Compose.MediaInsert.RecordVideo" = "Enregistra un vídeo"; "Accessibility.Scene.Compose.Send" = "Enviar"; -"Accessibility.Scene.Compose.Thread" = "Thread mode"; -"Accessibility.Scene.Gif.Search" = "Search GIF"; +"Accessibility.Scene.Compose.Thread" = "Mode de fil"; +"Accessibility.Scene.Gif.Search" = "Cerca un GIF"; "Accessibility.Scene.Gif.Title" = "GIPHY"; "Accessibility.Scene.Home.Compose" = "Redacta"; "Accessibility.Scene.Home.Drawer.AccountDropdown" = "Desplegable del compte"; "Accessibility.Scene.Home.Menu" = "Menú"; "Accessibility.Scene.ManageAccounts.Add" = "Afegir"; -"Accessibility.Scene.ManageAccounts.CurrentSignInUser" = "Current sign-in user: %@"; +"Accessibility.Scene.ManageAccounts.CurrentSignInUser" = "Usuari actual: %@"; "Accessibility.Scene.Search.History" = "Historial"; "Accessibility.Scene.Search.Save" = "Desa"; "Accessibility.Scene.Settings.Display.FontSize" = "Mida de la lletra"; -"Accessibility.Scene.SignIn.PleaseEnterMastodonDomainToSignIn" = "Please enter Mastodon domain to sign-in"; -"Accessibility.Scene.SignIn.TwitterClientAuthenticationKeySetting" = "Twitter client authentication key setting"; +"Accessibility.Scene.SignIn.PleaseEnterMastodonDomainToSignIn" = "Si us plau, introduïu el domini Mastodon per connectar-se"; +"Accessibility.Scene.SignIn.TwitterClientAuthenticationKeySetting" = "Ajustus de la clau d'autentificació del client de Twitter"; "Accessibility.Scene.Timeline.LoadGap" = "Carrega"; "Accessibility.Scene.User.Location" = "Ubicació"; "Accessibility.Scene.User.Tab.Favourite" = "Favorit"; "Accessibility.Scene.User.Tab.Media" = "Multimèdia"; "Accessibility.Scene.User.Tab.Status" = "Estats"; "Accessibility.Scene.User.Website" = "Lloc web"; -"Accessibility.VoiceOver.DoubleTapAndHoldToDisplayMenu" = "Double tap and hold to display menu"; -"Accessibility.VoiceOver.DoubleTapAndHoldToOpenTheAccountsPanel" = "Double tap and hold to open the accounts panel"; -"Accessibility.VoiceOver.DoubleTapToOpenProfile" = "Double tap to open profile"; -"Accessibility.VoiceOver.Selected" = "Selected"; +"Accessibility.VoiceOver.DoubleTapAndHoldToDisplayMenu" = "Feu doble toc i manteniu premut per mostrar el menú"; +"Accessibility.VoiceOver.DoubleTapAndHoldToOpenTheAccountsPanel" = "Feu doble toc i manteniu premut per el taulell de comptes"; +"Accessibility.VoiceOver.DoubleTapToOpenProfile" = "Feu doble toc per obrir el perfil"; +"Accessibility.VoiceOver.Selected" = "Seleccionats"; "Common.Alerts.AccountSuspended.Message" = "Twitter suspèn comptes que violen %@"; "Common.Alerts.AccountSuspended.Title" = "Compte suspès"; "Common.Alerts.AccountSuspended.TwitterRules" = "Normes de Twitter"; "Common.Alerts.AccountTemporarilyLocked.Message" = "Obre Twitter per desblocar"; "Common.Alerts.AccountTemporarilyLocked.Title" = "Compte blocat temporalment"; -"Common.Alerts.BlockUserConfirm.Title" = "Do you want to block %@?"; +"Common.Alerts.BlockUserConfirm.Title" = "Voleu blocar a %@?"; "Common.Alerts.BlockUserSuccess.Title" = "%@ ha estat blocat"; "Common.Alerts.CancelFollowRequest.Message" = "Canceŀlar la petició de seguiment a %@?"; -"Common.Alerts.DeleteTootConfirm.Message" = "Do you want to delete this toot?"; -"Common.Alerts.DeleteTootConfirm.Title" = "Delete Toot"; -"Common.Alerts.DeleteTweetConfirm.Message" = "Do you want to delete this tweet?"; -"Common.Alerts.DeleteTweetConfirm.Title" = "Delete Tweet"; +"Common.Alerts.DeleteTootConfirm.Message" = "Voleu suprimir aquest Tut?"; +"Common.Alerts.DeleteTootConfirm.Title" = "Esborra Tut"; +"Common.Alerts.DeleteTweetConfirm.Message" = "Voleu suprimir aquesta piulada?"; +"Common.Alerts.DeleteTweetConfirm.Title" = "Esborra piulada"; "Common.Alerts.FailedToAddListMember.Message" = "Si us plau, torna-ho a provar"; -"Common.Alerts.FailedToAddListMember.Title" = "Failed to Add List Member"; +"Common.Alerts.FailedToAddListMember.Title" = "No s'ha pogut afegir un membre a la llista"; "Common.Alerts.FailedToBlockUser.Message" = "Si us plau, torna-ho a provar"; "Common.Alerts.FailedToBlockUser.Title" = "Error al blocar %@"; "Common.Alerts.FailedToDeleteList.Message" = "Si us plau, torna-ho a provar"; -"Common.Alerts.FailedToDeleteList.Title" = "Failed to Delete List"; +"Common.Alerts.FailedToDeleteList.Title" = "No s'ha pogut esborrar la llista"; "Common.Alerts.FailedToDeleteToot.Message" = "Si us plau, torna-ho a provar"; -"Common.Alerts.FailedToDeleteToot.Title" = "Failed to Delete Toot"; +"Common.Alerts.FailedToDeleteToot.Title" = "No s'ha pogut esborrar el Tut"; "Common.Alerts.FailedToDeleteTweet.Message" = "Si us plau, torna-ho a provar"; "Common.Alerts.FailedToDeleteTweet.Title" = "Error al esborrar piulada"; "Common.Alerts.FailedToFollowing.Message" = "Si us plau, torna-ho a provar"; @@ -91,13 +91,13 @@ "Common.Alerts.FailedToMuteUser.Message" = "Si us plau, torna-ho a provar"; "Common.Alerts.FailedToMuteUser.Title" = "Error al silenciar %@"; "Common.Alerts.FailedToRemoveListMember.Message" = "Si us plau, torna-ho a provar"; -"Common.Alerts.FailedToRemoveListMember.Title" = "Failed to Remove List Member"; +"Common.Alerts.FailedToRemoveListMember.Title" = "No s'ha pogut eliminar un membre de la llista"; "Common.Alerts.FailedToReportAndBlockUser.Message" = "Si us plau, torna-ho a provar"; "Common.Alerts.FailedToReportAndBlockUser.Title" = "Error al denunciar i blocar %@"; "Common.Alerts.FailedToReportUser.Message" = "Si us plau, torna-ho a provar"; "Common.Alerts.FailedToReportUser.Title" = "Error al denunciar %@"; -"Common.Alerts.FailedToSendMessage.Message" = "Failed to send message"; -"Common.Alerts.FailedToSendMessage.Title" = "Sending message"; +"Common.Alerts.FailedToSendMessage.Message" = "No s'ha pogut enviar el missatge"; +"Common.Alerts.FailedToSendMessage.Title" = "S'està enviant el missatge"; "Common.Alerts.FailedToUnblockUser.Message" = "Si us plau, torna-ho a provar"; "Common.Alerts.FailedToUnblockUser.Title" = "Error al desblocar %@"; "Common.Alerts.FailedToUnfollowing.Message" = "Si us plau, torna-ho a provar"; @@ -107,61 +107,63 @@ "Common.Alerts.FollowingRequestSent.Title" = "Petició de seguiment enviada"; "Common.Alerts.FollowingSuccess.Title" = "Seguiment realitzat"; "Common.Alerts.ListDeleted.Message" = "Si us plau, torna-ho a provar"; -"Common.Alerts.ListDeleted.Title" = "List Deleted"; -"Common.Alerts.ListMemberRemoved.Title" = "List Member Removed"; +"Common.Alerts.ListDeleted.Title" = "Llista esborrada"; +"Common.Alerts.ListMemberRemoved.Title" = "Membre de la llista eliminat"; "Common.Alerts.MediaSaveFail.Message" = "Si us plau, torna-ho a provar"; "Common.Alerts.MediaSaveFail.Title" = "No s'ha pogut desar el mitjà"; "Common.Alerts.MediaSaved.Title" = "S'ha desat el mitjà"; "Common.Alerts.MediaSaving.Title" = "S'està desant el mitjà"; -"Common.Alerts.MediaSharing.Title" = "Media will be shared after download is completed"; -"Common.Alerts.MuteUserConfirm.Title" = "Do you want to mute %@?"; +"Common.Alerts.MediaSharing.Title" = "Mèdia serà compartida després de que finalitzi la descàrrega"; +"Common.Alerts.MuteUserConfirm.Title" = "Voleu silenciar a %@?"; "Common.Alerts.MuteUserSuccess.Title" = "%@ ha estat silenciat"; "Common.Alerts.NoTweetsFound.Title" = "No s'han trobat piulades"; "Common.Alerts.PermissionDeniedFriendshipBlocked.Message" = "Has estat blocat de seguir aquest compte a petició de l'usuari"; "Common.Alerts.PermissionDeniedFriendshipBlocked.Title" = "Permís denegat"; "Common.Alerts.PermissionDeniedNotAuthorized.Message" = "Ho sento, no estàs autoritzat"; "Common.Alerts.PermissionDeniedNotAuthorized.Title" = "Permís denegat"; -"Common.Alerts.PhotoCopied.Title" = "Photo Copied"; +"Common.Alerts.PhotoCopied.Title" = "Fotografia copiada"; "Common.Alerts.PhotoCopyFail.Message" = "Si us plau, torna-ho a provar"; -"Common.Alerts.PhotoCopyFail.Title" = "Failed to Copy Photo"; +"Common.Alerts.PhotoCopyFail.Title" = "No s'ha pogut copiar la fotografia"; "Common.Alerts.PhotoSaveFail.Message" = "Si us plau, torna-ho a provar"; "Common.Alerts.PhotoSaveFail.Title" = "Error al desar la imatge"; "Common.Alerts.PhotoSaved.Title" = "Imatge desada"; -"Common.Alerts.PostFailInvalidPoll.Message" = "Poll has empty field. Please fulfill the field then try again"; -"Common.Alerts.PostFailInvalidPoll.Title" = "Failed to Publish"; +"Common.Alerts.PostFailInvalidPoll.Message" = "L'enquesta té camps buits. Si us plau, empleneu-los tots i torneu a provar"; +"Common.Alerts.PostFailInvalidPoll.Title" = "No s'ha pogut publicar"; "Common.Alerts.RateLimitExceeded.Message" = "S'ha arribat al límit d'ús de la API de Twitter"; "Common.Alerts.RateLimitExceeded.Title" = "Taxa d'ús excedida"; "Common.Alerts.ReportAndBlockUserSuccess.Title" = "%@ ha estat denunciat per publicitat abusiva i blocat"; "Common.Alerts.ReportUserSuccess.Title" = "%@ ha estat denunciat per publicitat abusiva"; -"Common.Alerts.SignOutUserConfirm.Message" = "Do you want to sign out?"; -"Common.Alerts.SignOutUserConfirm.Title" = "Sign out"; +"Common.Alerts.RequestThrottle.Message" = "Operació massa freqüent. Si us plau, torneu-ho a provar més tard"; +"Common.Alerts.RequestThrottle.Title" = "Regula"; +"Common.Alerts.SignOutUserConfirm.Message" = "Voleu finalitzar la sessió?"; +"Common.Alerts.SignOutUserConfirm.Title" = "Finalitza la sessió"; "Common.Alerts.TooManyRequests.Title" = "Massa soŀlicituds"; -"Common.Alerts.TootDeleted.Title" = "Toot Deleted"; -"Common.Alerts.TootFail.DraftSavedMessage" = "Your toot has been saved to Drafts."; +"Common.Alerts.TootDeleted.Title" = "Tut esborrat"; +"Common.Alerts.TootFail.DraftSavedMessage" = "El vostre Tut s'ha desat a esborranys."; "Common.Alerts.TootFail.Message" = "Si us plau, torna-ho a provar"; -"Common.Alerts.TootFail.Title" = "Failed to Toot"; -"Common.Alerts.TootPosted.Title" = "Toot Posted"; -"Common.Alerts.TootSending.Title" = "Sending toot"; +"Common.Alerts.TootFail.Title" = "No s'ha pogut fer Tut"; +"Common.Alerts.TootPosted.Title" = "Tut publicat"; +"Common.Alerts.TootSending.Title" = "Enviant Tut"; "Common.Alerts.TweetDeleted.Title" = "Piulada esborrada"; -"Common.Alerts.TweetFail.DraftSavedMessage" = "Your tweet has been saved to Drafts."; +"Common.Alerts.TweetFail.DraftSavedMessage" = "La vostra piulada s'ha desat a esborranys."; "Common.Alerts.TweetFail.Message" = "Si us plau, torna-ho a provar"; "Common.Alerts.TweetFail.Title" = "Error al piular"; -"Common.Alerts.TweetPosted.Title" = "Tweet Posted"; +"Common.Alerts.TweetPosted.Title" = "Piulada publicada"; "Common.Alerts.TweetSending.Title" = "S'està enviant la piulada"; "Common.Alerts.TweetSent.Title" = "Piulada enviada"; -"Common.Alerts.UnblockUserConfirm.Title" = "Do you want to unblock %@?"; +"Common.Alerts.UnblockUserConfirm.Title" = "Voleu desblocar a %@?"; "Common.Alerts.UnblockUserSuccess.Title" = "%@ ha estat desblocat"; "Common.Alerts.UnfollowUser.Message" = "Deixar de seguir l'usuari %@?"; "Common.Alerts.UnfollowingSuccess.Title" = "S'ha deixat de seguir"; -"Common.Alerts.UnmuteUserConfirm.Title" = "Do you want to unmute %@?"; +"Common.Alerts.UnmuteUserConfirm.Title" = "Voleu dessilenciar a %@?"; "Common.Alerts.UnmuteUserSuccess.Title" = "%@ ha estat silenciat"; "Common.Controls.Actions.Add" = "Afegir"; -"Common.Controls.Actions.Browse" = "Browse"; +"Common.Controls.Actions.Browse" = "Mostra"; "Common.Controls.Actions.Cancel" = "Canceŀla"; -"Common.Controls.Actions.Clear" = "Clear"; +"Common.Controls.Actions.Clear" = "Neteja"; "Common.Controls.Actions.Confirm" = "Confirma"; -"Common.Controls.Actions.Copy" = "Copy"; -"Common.Controls.Actions.Delete" = "Delete"; +"Common.Controls.Actions.Copy" = "Copia"; +"Common.Controls.Actions.Delete" = "Esborra "; "Common.Controls.Actions.Done" = "Fet"; "Common.Controls.Actions.Edit" = "Edita"; "Common.Controls.Actions.Ok" = "D'acord"; @@ -170,30 +172,32 @@ "Common.Controls.Actions.Remove" = "Elimina"; "Common.Controls.Actions.Save" = "Desa"; "Common.Controls.Actions.SavePhoto" = "Desa la foto"; -"Common.Controls.Actions.Share" = "Share"; +"Common.Controls.Actions.Share" = "Comparteix"; "Common.Controls.Actions.ShareLink" = "Comparteix l'enllaç"; -"Common.Controls.Actions.ShareMedia" = "Share media"; -"Common.Controls.Actions.ShareMediaMenu.Link" = "Link"; +"Common.Controls.Actions.ShareMedia" = "Comparteix multimèdia"; +"Common.Controls.Actions.ShareMediaMenu.Link" = "Enllaç"; "Common.Controls.Actions.ShareMediaMenu.Media" = "Multimèdia"; "Common.Controls.Actions.SignIn" = "Iniciar sessió"; -"Common.Controls.Actions.SignOut" = "Sign out"; +"Common.Controls.Actions.SignOut" = "Tanca sessió"; "Common.Controls.Actions.TakePhoto" = "Fes una foto"; "Common.Controls.Actions.Yes" = "Sí"; +"Common.Controls.EmptyState.NoResults" = "Cap resultat"; +"Common.Controls.EmptyState.UnableToAccess" = "Unable to access"; "Common.Controls.Friendship.Actions.Block" = "Bloca"; -"Common.Controls.Friendship.Actions.Blocked" = "Blocked"; +"Common.Controls.Friendship.Actions.Blocked" = "Blocat"; "Common.Controls.Friendship.Actions.Follow" = "Segueix"; "Common.Controls.Friendship.Actions.Following" = "Seguint"; "Common.Controls.Friendship.Actions.Mute" = "Silencia"; "Common.Controls.Friendship.Actions.Pending" = "Pendents"; "Common.Controls.Friendship.Actions.Report" = "Denuncia"; "Common.Controls.Friendship.Actions.ReportAndBlock" = "Denuncia i bloca"; -"Common.Controls.Friendship.Actions.Request" = "Request"; +"Common.Controls.Friendship.Actions.Request" = "Sol·licita"; "Common.Controls.Friendship.Actions.Unblock" = "Desbloca"; "Common.Controls.Friendship.Actions.Unfollow" = "Deixa de seguir"; "Common.Controls.Friendship.Actions.Unmute" = "Deixar de silenciar"; "Common.Controls.Friendship.BlockUser" = "Bloca %@"; -"Common.Controls.Friendship.DoYouWantToReportAnd BlockUser" = "Do you want to report and block %@"; -"Common.Controls.Friendship.DoYouWantToReportUser" = "Do you want to report %@"; +"Common.Controls.Friendship.DoYouWantToReportAndBlockUser" = "Do you want to report and block %@"; +"Common.Controls.Friendship.DoYouWantToReportUser" = "Voleu informar de %@"; "Common.Controls.Friendship.Follower" = "seguidor"; "Common.Controls.Friendship.Followers" = "seguidors"; "Common.Controls.Friendship.FollowsYou" = "Et segueix"; @@ -201,24 +205,30 @@ "Common.Controls.Friendship.UserIsFollowingYou" = "%@ et segueix"; "Common.Controls.Friendship.UserIsNotFollowingYou" = "%@ no et segueix"; "Common.Controls.Ios.PhotoLibrary" = "Galeria d'imatges"; -"Common.Controls.List.NoResults" = "No results"; +"Common.Controls.List.NoResults" = "Cap resultat"; "Common.Controls.ProfileDashboard.Followers" = "Seguidors"; "Common.Controls.ProfileDashboard.Following" = "Seguint"; "Common.Controls.ProfileDashboard.Listed" = "Llistat"; "Common.Controls.Status.Actions.Bookmark" = "Marcador"; -"Common.Controls.Status.Actions.Boost" = "Boost"; +"Common.Controls.Status.Actions.Boost" = "Impulsa"; "Common.Controls.Status.Actions.CopyLink" = "Copia l'enllaç"; "Common.Controls.Status.Actions.CopyText" = "Copia el text"; "Common.Controls.Status.Actions.DeleteTweet" = "Esborra la piulada"; -"Common.Controls.Status.Actions.PinOnProfile" = "Pin on Profile"; +"Common.Controls.Status.Actions.Like" = "M'agrada"; +"Common.Controls.Status.Actions.PinOnProfile" = "Fixa en el perfil"; "Common.Controls.Status.Actions.Quote" = "Cita"; +"Common.Controls.Status.Actions.Reply" = "Resposta"; +"Common.Controls.Status.Actions.Repost" = "Repost"; "Common.Controls.Status.Actions.Retweet" = "Repiular"; -"Common.Controls.Status.Actions.SaveMedia" = "Save media"; -"Common.Controls.Status.Actions.Share" = "Share"; -"Common.Controls.Status.Actions.ShareContent" = "Share content"; +"Common.Controls.Status.Actions.SaveMedia" = "Desa multimèdia"; +"Common.Controls.Status.Actions.Share" = "Comparteix"; +"Common.Controls.Status.Actions.ShareContent" = "Comparteix el contingut"; "Common.Controls.Status.Actions.ShareLink" = "Comparteix l'enllaç"; -"Common.Controls.Status.Actions.Translate" = "Translate"; -"Common.Controls.Status.Actions.UnpinFromProfile" = "Unpin from Profile"; +"Common.Controls.Status.Actions.Translate" = "Tradueix"; +"Common.Controls.Status.Actions.UndoBoost" = "Undo Boost"; +"Common.Controls.Status.Actions.UndoRepost" = "Undo Repost"; +"Common.Controls.Status.Actions.UndoRetweet" = "Undo Retweet"; +"Common.Controls.Status.Actions.UnpinFromProfile" = "Treu del perfil"; "Common.Controls.Status.Actions.Vote" = "Votació"; "Common.Controls.Status.Media" = "Multimèdia"; "Common.Controls.Status.Poll.Expired" = "Tancada"; @@ -226,17 +236,17 @@ "Common.Controls.Status.Poll.TotalPerson" = "%@ persona"; "Common.Controls.Status.Poll.TotalVote" = "%@ vots"; "Common.Controls.Status.Poll.TotalVotes" = "%@ vots"; -"Common.Controls.Status.ReplySettings.PeopleUserFollowsOrMentionedCanReply" = "People %@ follows or mentioned can reply."; -"Common.Controls.Status.ReplySettings.PeopleUserMentionedCanReply" = "People %@ mentioned can reply."; -"Common.Controls.Status.Thread.Show" = "Show this thread"; -"Common.Controls.Status.UserBoosted" = "%@ boosted"; +"Common.Controls.Status.ReplySettings.PeopleUserFollowsOrMentionedCanReply" = "Gent %@ que et segueix o menciona pot respondre."; +"Common.Controls.Status.ReplySettings.PeopleUserMentionedCanReply" = "Gent %@ mencionada por respondre."; +"Common.Controls.Status.Thread.Show" = "Mostra aquest fil"; +"Common.Controls.Status.UserBoosted" = "%@ impulsat"; "Common.Controls.Status.UserRetweeted" = "%@ repiulades"; -"Common.Controls.Status.YouBoosted" = "You boosted"; -"Common.Controls.Status.YouRetweeted" = "You retweeted"; +"Common.Controls.Status.YouBoosted" = "Heu impulsat"; +"Common.Controls.Status.YouRetweeted" = "Heu repiulat"; "Common.Controls.Timeline.LoadMore" = "Carrega'n més"; -"Common.Controls.User.Actions.AddRemoveFromLists" = "Add/remove from Lists"; -"Common.Controls.User.Actions.ViewListed" = "View Listed"; -"Common.Controls.User.Actions.ViewLists" = "View Lists"; +"Common.Controls.User.Actions.AddRemoveFromLists" = "Afegeix/elimina de les llistes"; +"Common.Controls.User.Actions.ViewListed" = "Mostra en llistes"; +"Common.Controls.User.Actions.ViewLists" = "Mostra les llistes"; "Common.Countable.Like.Multiple" = "%@ agradaments"; "Common.Countable.Like.Single" = "%@ agradament"; "Common.Countable.List.Multiple" = "%@ llistes"; @@ -255,39 +265,44 @@ "Common.Countable.Tweet.Single" = "%@ piulada"; "Common.Notification.Favourite" = "%@ han impulsat el vostre tut"; "Common.Notification.Follow" = "%@ et segueix"; -"Common.Notification.FollowRequest" = "%@ has requested to follow you"; -"Common.Notification.FollowRequestAction.Approve" = "Approve"; -"Common.Notification.FollowRequestAction.Deny" = "Deny"; -"Common.Notification.FollowRequestResponse.FollowRequestApproved" = "Follow Request Approved"; -"Common.Notification.FollowRequestResponse.FollowRequestDenied" = "Follow Request Denied"; +"Common.Notification.FollowRequest" = "%@ ha sol·licitat seguir-te"; +"Common.Notification.FollowRequestAction.Approve" = "Aprova"; +"Common.Notification.FollowRequestAction.Deny" = "Refusa"; +"Common.Notification.FollowRequestResponse.FollowRequestApproved" = "Sol·licitud de seguiment aprovada"; +"Common.Notification.FollowRequestResponse.FollowRequestDenied" = "Sol·licitud de seguiment refusada"; "Common.Notification.Mentions" = "%@ t'ha mencionat"; -"Common.Notification.Messages.Content" = "%@ sent you a message"; -"Common.Notification.Messages.Title" = "New direct message"; +"Common.Notification.Messages.Content" = "%@ us ha enviat un missatge"; +"Common.Notification.Messages.Title" = "Nou missatge directe"; "Common.Notification.OwnPoll" = "La vostra enquesta ha finalitzar"; "Common.Notification.Poll" = "Una votació en que has participat ha finalitzat"; "Common.Notification.Reblog" = "%@ han impulsat el vostre tut"; -"Common.Notification.Status" = "%@ just posted"; -"Common.NotificationChannel.BackgroundProgresses.Name" = "Background progresses"; +"Common.Notification.Status" = "%@ acaba de publicar"; +"Common.NotificationChannel.BackgroundProgresses.Name" = "En curs en segon pla"; "Common.NotificationChannel.ContentInteractions.Description" = "Interaccions com mencions i repiulades"; "Common.NotificationChannel.ContentInteractions.Name" = "Interaccions"; -"Common.NotificationChannel.ContentMessages.Description" = "Direct messages"; +"Common.NotificationChannel.ContentMessages.Description" = "Missatges directes"; "Common.NotificationChannel.ContentMessages.Name" = "Missatges"; "Scene.Authentication.Title" = "Autenticació"; "Scene.Bookmark.Title" = "Marcador"; +"Scene.Column.Actions.CloseColumn" = "Close column"; +"Scene.Column.Actions.MoveLeft" = "Move left"; +"Scene.Column.Actions.MoveRight" = "Move right"; +"Scene.Column.Actions.OpenInNewColumn" = "Open in new column"; +"Scene.Column.Title" = "New Column"; "Scene.Compose.And" = ", "; "Scene.Compose.CwPlaceholder" = "Escriviu la vostra advertència aquí"; "Scene.Compose.LastEnd" = " i "; -"Scene.Compose.Media.Caption.Add" = "Add Caption"; -"Scene.Compose.Media.Caption.AddADescriptionForThisImage" = "Add a description for this image"; -"Scene.Compose.Media.Caption.Remove" = "Remove Caption"; -"Scene.Compose.Media.Caption.Update" = "Update Caption"; +"Scene.Compose.Media.Caption.Add" = "Afegeix llegenda"; +"Scene.Compose.Media.Caption.AddADescriptionForThisImage" = "Afegeix descripció d'aquesta imatge"; +"Scene.Compose.Media.Caption.Remove" = "Esborra llegenda"; +"Scene.Compose.Media.Caption.Update" = "Actualitza llegenda"; "Scene.Compose.Media.Preview" = "Previsualitza"; "Scene.Compose.Media.Remove" = "Elimina"; "Scene.Compose.OthersInThisConversation" = "Altres en aquesta conversa:"; "Scene.Compose.Placeholder" = "Què està passant?"; -"Scene.Compose.ReplySettings.EveryoneCanReply" = "Everyone can peply"; -"Scene.Compose.ReplySettings.OnlyPeopleYouMentionCanReply" = "Only people you mention can reply"; -"Scene.Compose.ReplySettings.PeopleYouFollowCanReply" = "People you follow can reply"; +"Scene.Compose.ReplySettings.EveryoneCanReply" = "Tothom pot respondre"; +"Scene.Compose.ReplySettings.OnlyPeopleYouMentionCanReply" = "Només la gent que menciones pot respondre"; +"Scene.Compose.ReplySettings.PeopleYouFollowCanReply" = "La gent que seguiu pot respondre"; "Scene.Compose.ReplyTo" = "Resposta a …"; "Scene.Compose.ReplyingTo" = "Responent a"; "Scene.Compose.SaveDraft.Action" = "Desa l'esborrany"; @@ -299,10 +314,10 @@ "Scene.Compose.Visibility.Private" = "Privada"; "Scene.Compose.Visibility.Public" = "Pública"; "Scene.Compose.Visibility.Unlisted" = "Cap llista"; -"Scene.Compose.VisibilityDescription.Direct" = "Visible for mentioned users only"; -"Scene.Compose.VisibilityDescription.Private" = "Visible for followers only"; -"Scene.Compose.VisibilityDescription.Public" = "Visible for all, shown in public timelines"; -"Scene.Compose.VisibilityDescription.Unlisted" = "Visible for all, but not in public timelines"; +"Scene.Compose.VisibilityDescription.Direct" = "Visible només per als usuaris esmentats"; +"Scene.Compose.VisibilityDescription.Private" = "Visible només per als seguidors"; +"Scene.Compose.VisibilityDescription.Public" = "Visible per tothom, mostra a les cronologies públiques"; +"Scene.Compose.VisibilityDescription.Unlisted" = "Visible per tothom, però no en cronologies públiques"; "Scene.Compose.Vote.Expiration.1Day" = "1 dia"; "Scene.Compose.Vote.Expiration.1Hour" = "1 hora"; "Scene.Compose.Vote.Expiration.30Min" = "30 minuts"; @@ -311,17 +326,23 @@ "Scene.Compose.Vote.Expiration.6Hour" = "6 hores"; "Scene.Compose.Vote.Expiration.7Day" = "7 dies"; "Scene.Compose.Vote.Multiple" = "Elecció múltiple"; -"Scene.Compose.Vote.PlaceholderIndex" = "Choice %d"; +"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"; "Scene.Drawer.ManageAccounts" = "Gestiona els comptes"; "Scene.Drawer.SignIn" = "Iniciar sessió"; -"Scene.Federated.Title" = "Federated"; +"Scene.Federated.Title" = "Federat"; "Scene.Followers.Title" = "Seguidors"; "Scene.Following.Title" = "Seguint"; +"Scene.History.Clear" = "Neteja"; +"Scene.History.Scope.Toot" = "Tut"; +"Scene.History.Scope.Tweet" = "Piulada"; +"Scene.History.Scope.User" = "Usuari"; +"Scene.History.Title" = "Historial"; "Scene.Likes.Title" = "M'agrada"; "Scene.Listed.Title" = "Llistat"; "Scene.Lists.Icons.Create" = "Crea llista"; @@ -331,7 +352,7 @@ "Scene.Lists.Title" = "Llistes"; "Scene.ListsDetails.AddMembers" = "Afegir membres"; "Scene.ListsDetails.DeleteListConfirm" = "Suprimeix aquesta llista: %@"; -"Scene.ListsDetails.DeleteListTitle" = "Delete this list"; +"Scene.ListsDetails.DeleteListTitle" = "Elimina aquesta llista"; "Scene.ListsDetails.Descriptions.MultipleMembers" = "%d Membres"; "Scene.ListsDetails.Descriptions.MultipleSubscribers" = "%d subscriptors"; "Scene.ListsDetails.Descriptions.SingleMember" = "1 Membre"; @@ -354,26 +375,27 @@ "Scene.ListsModify.Name" = "Nom"; "Scene.ListsModify.Private" = "Privada"; "Scene.ListsUsers.Add.Search" = "Cerca persones"; -"Scene.ListsUsers.Add.SearchWithinPeopleYouFollow" = "Search within people you follow"; +"Scene.ListsUsers.Add.SearchWithinPeopleYouFollow" = "Cerca entre les persones que segueixes"; "Scene.ListsUsers.Add.Title" = "Afegeix membre"; "Scene.ListsUsers.MenuActions.Add" = "Afegir"; "Scene.ListsUsers.MenuActions.Remove" = "Elimina"; "Scene.Local.Title" = "Local"; "Scene.ManageAccounts.DeleteAccount" = "Esborra el compte"; +"Scene.ManageAccounts.OpenInNewWindow" = "Open in new window"; "Scene.ManageAccounts.Title" = "Comptes"; "Scene.Mentions.Title" = "Mencions"; -"Scene.Messages.Action.CopyText" = "Copy message text"; -"Scene.Messages.Action.Delete" = "Delete message for you"; -"Scene.Messages.Error.NotSupported" = "The Current account does not support direct messages"; -"Scene.Messages.Expanded.Photo" = "[Photo]"; -"Scene.Messages.Icon.Failed" = "Send message failed"; +"Scene.Messages.Action.CopyText" = "Copia el text del missatge"; +"Scene.Messages.Action.Delete" = "Esborra el missatge per tu"; +"Scene.Messages.Error.NotSupported" = "El compte actual no permet missatges directes"; +"Scene.Messages.Expanded.Photo" = "Sense Foto"; +"Scene.Messages.Icon.Failed" = "No s'ha pogut enviar el missatge"; "Scene.Messages.NewConversation.Search" = "Cerca persones"; -"Scene.Messages.NewConversation.Title" = "Find people"; +"Scene.Messages.NewConversation.Title" = "Troba Gent"; "Scene.Messages.Title" = "Missatges"; "Scene.Notification.Tabs.All" = "Tot"; "Scene.Notification.Tabs.Mentions" = "Mencions"; "Scene.Notification.Title" = "Notificació"; -"Scene.Profile.Fields.JoinedInDate" = "Joined in %@"; +"Scene.Profile.Fields.JoinedInDate" = "Unit a %@"; "Scene.Profile.Filter.All" = "Totes les piulades"; "Scene.Profile.Filter.ExcludeReplies" = "Amaga les respostes"; "Scene.Profile.HideReply" = "Amaga la resposta"; @@ -382,12 +404,12 @@ "Scene.Profile.Title" = "Jo"; "Scene.Search.SavedSearch" = "Cerca desada"; "Scene.Search.SearchBar.Placeholder" = "Cerca piulades o usuaris"; -"Scene.Search.ShowLess" = "Show less"; -"Scene.Search.ShowMore" = "Show more"; +"Scene.Search.ShowLess" = "Mostra menys"; +"Scene.Search.ShowMore" = "Mostra més"; "Scene.Search.Tabs.Hashtag" = "Etiqueta"; "Scene.Search.Tabs.Media" = "Multimèdia"; -"Scene.Search.Tabs.People" = "People"; -"Scene.Search.Tabs.Toots" = "Toots"; +"Scene.Search.Tabs.People" = "Persones"; +"Scene.Search.Tabs.Toots" = "Tuts"; "Scene.Search.Tabs.Tweets" = "Piulades"; "Scene.Search.Tabs.Users" = "Usuaris"; "Scene.Search.Title" = "Cerca"; @@ -398,22 +420,21 @@ Encara en una fase inicial."; "Scene.Settings.About.Logo.BackgroundShadow" = "Quant al ombrejat del logo del fons de la pàgina"; "Scene.Settings.About.Title" = "Quant a"; "Scene.Settings.About.Version" = "Versió %@"; -"Scene.Settings.Account.AccountSettings" = "Account Settings"; -"Scene.Settings.Account.BlockedPeople" = "Blocked People"; -"Scene.Settings.Account.MuteAndBlock" = "Mute and Block"; -"Scene.Settings.Account.MutedPeople" = "Muted People"; -"Scene.Settings.Account.Title" = "Account"; -"Scene.Settings.Appearance.AmoledOptimizedMode" = "AMOLED optimized mode"; -"Scene.Settings.Appearance.AppIcon" = "App Icon"; +"Scene.Settings.Account.BlockedPeople" = "Persones blocades"; +"Scene.Settings.Account.MuteAndBlock" = "Silencia i bloca"; +"Scene.Settings.Account.MutedPeople" = "Persones silenciades"; +"Scene.Settings.Account.Title" = "Compte"; +"Scene.Settings.Appearance.AmoledOptimizedMode" = "Mode optimitzat AMOLED"; +"Scene.Settings.Appearance.AppIcon" = "Icona de l'aplicació"; "Scene.Settings.Appearance.HighlightColor" = "Color de realçament"; "Scene.Settings.Appearance.PickColor" = "Tria un color"; "Scene.Settings.Appearance.ScrollingTimeline.AppBar" = "Amaga la barra de l'aplicació al desplaçar-te"; -"Scene.Settings.Appearance.ScrollingTimeline.Fab" = "Hide FAB when scrolling"; +"Scene.Settings.Appearance.ScrollingTimeline.Fab" = "Amaga FAB al desplaçar-se"; "Scene.Settings.Appearance.ScrollingTimeline.TabBar" = "Amaga la barra de pestanyes al desplaçar-te"; -"Scene.Settings.Appearance.SectionHeader.ScrollingTimeline" = "Línia de temps"; +"Scene.Settings.Appearance.SectionHeader.ScrollingTimeline" = "Cronologia"; "Scene.Settings.Appearance.SectionHeader.TabPosition" = "Posició de la pestanya"; "Scene.Settings.Appearance.SectionHeader.Theme" = "Tema"; -"Scene.Settings.Appearance.SectionHeader.Translation" = "Translation"; +"Scene.Settings.Appearance.SectionHeader.Translation" = "Traducció"; "Scene.Settings.Appearance.TabPosition.Bottom" = "Baix"; "Scene.Settings.Appearance.TabPosition.Top" = "Dalt"; "Scene.Settings.Appearance.Theme.Auto" = "Automàtic"; @@ -423,17 +444,34 @@ Encara en una fase inicial."; "Scene.Settings.Appearance.Translation.Always" = "Sempre"; "Scene.Settings.Appearance.Translation.Auto" = "Automàtic"; "Scene.Settings.Appearance.Translation.Off" = "Desactivada"; -"Scene.Settings.Appearance.Translation.Service" = "Service"; -"Scene.Settings.Appearance.Translation.TranslateButton" = "Translate button"; +"Scene.Settings.Appearance.Translation.Service" = "Servei "; +"Scene.Settings.Appearance.Translation.TranslateButton" = "Botó de traducció"; +"Scene.Settings.Behaviors.HistorySection.EnableHistoryRecord" = "Habilita el registre de l'historial"; +"Scene.Settings.Behaviors.HistorySection.History" = "Historial"; +"Scene.Settings.Behaviors.TabBarSection.ShowTabBarLabels" = "Mostra etiquetes a la barra"; +"Scene.Settings.Behaviors.TabBarSection.TabBar" = "Barra"; +"Scene.Settings.Behaviors.TabBarSection.TapTabBarScrollToTop" = "Feu un toc a la barra per anar a dalt"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.AutomaticallyRefreshTimeline" = "Actualitza automàticament la cronologia"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshInterval" = "Interval d'actualització"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.120Seconds" = "120 segons"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.300Seconds" = "300 segons"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.30Seconds" = "30 segons"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.60Seconds" = "60 segons"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTop" = "Restableix a dalt"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTopOption.DoubleTap" = "Doble toc"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTopOption.SingleTap" = "Toc únic"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.TimelineRefreshing" = "Actualització de la cronologia"; +"Scene.Settings.Behaviors.Title" = "Comportaments"; "Scene.Settings.Display.DateFormat.Absolute" = "Absoluta"; "Scene.Settings.Display.DateFormat.Relative" = "Relativa"; "Scene.Settings.Display.Media.Always" = "Sempre"; "Scene.Settings.Display.Media.AutoPlayback" = "Reproducció automàtica"; "Scene.Settings.Display.Media.Automatic" = "Automàtic"; "Scene.Settings.Display.Media.MediaPreviews" = "Previsualitzacions dels mitjans"; -"Scene.Settings.Display.Media.MuteByDefault" = "Mute by default"; +"Scene.Settings.Display.Media.MuteByDefault" = "Silenciat per defecte"; "Scene.Settings.Display.Media.Off" = "Desactivada"; "Scene.Settings.Display.Preview.ThankForUsingTwidereX" = "Gràcies per utilitzar @TwidereProject!"; +"Scene.Settings.Display.SectionHeader.Avatar" = "Avatar"; "Scene.Settings.Display.SectionHeader.DateFormat" = "Format de la data"; "Scene.Settings.Display.SectionHeader.Media" = "Multimèdia"; "Scene.Settings.Display.SectionHeader.Preview" = "Previsualitza"; @@ -444,52 +482,56 @@ Encara en una fase inicial."; "Scene.Settings.Display.Text.UseTheSystemFontSize" = "Empra la mida de la lletra del sistema"; "Scene.Settings.Display.Title" = "Visualització"; "Scene.Settings.Display.UrlPreview" = "Previsualització d'Url"; -"Scene.Settings.Layout.Actions.Drawer" = "Drawer actions"; -"Scene.Settings.Layout.Actions.Tabbar" = "Tabbar actions"; -"Scene.Settings.Layout.Desc.Content" = "Choose and arrange up to 5 actions that will appear on the tabbar (The local and federal timelines will only be displayed in Mastodon.)"; -"Scene.Settings.Layout.Desc.Title" = "Custom Layout"; -"Scene.Settings.Layout.Title" = "Layout"; -"Scene.Settings.Misc.Nitter.Dialog.Information.Content" = "Due to the limitation of Twitter API, some data might not be able to fetch from Twitter, you can use a third-party data provider to provide these data. Twidere does not take any responsibility for them."; -"Scene.Settings.Misc.Nitter.Dialog.Information.Title" = "Third party Twitter data provider"; -"Scene.Settings.Misc.Nitter.Dialog.Usage.Content" = "- Twitter status threading"; +"Scene.Settings.Layout.Actions.Drawer" = "Accions de la caixa"; +"Scene.Settings.Layout.Actions.Tabbar" = "Accions de la barra"; +"Scene.Settings.Layout.Desc.Content" = "Trieu i ordeneu fins a 5 accions que es mostraran a la barra (les cronologies locals i federades només ho faran a Mastodon)"; +"Scene.Settings.Layout.Desc.Title" = "Disposició personalitzada"; +"Scene.Settings.Layout.Title" = "Disposició"; +"Scene.Settings.Misc.Nitter.Dialog.Information.Content" = "Degut a les limitacions de l'API de Twitter, algunes dades no es poden obtenir de Twitter, podeu emprar un tercer proveïdor per aconseguir aquestes dades. Twidere no assumeix cap responsabilitat en el seu nom."; +"Scene.Settings.Misc.Nitter.Dialog.Information.Title" = "Proveïdors de dades de tercers per Twitter"; +"Scene.Settings.Misc.Nitter.Dialog.Usage.Content" = "- Estat de fils de Twitter"; "Scene.Settings.Misc.Nitter.Dialog.Usage.ProjectButton" = "URL del projecte"; -"Scene.Settings.Misc.Nitter.Dialog.Usage.Title" = "Using Third-party data provider in"; -"Scene.Settings.Misc.Nitter.Input.Description" = "Alternative Twitter front-end focused on privacy."; -"Scene.Settings.Misc.Nitter.Input.Invalid" = "Nitter instance URL is invalid, e.g. https://nitter.net"; -"Scene.Settings.Misc.Nitter.Input.Placeholder" = "Nitter Instance"; -"Scene.Settings.Misc.Nitter.Input.Value" = "Instance URL"; -"Scene.Settings.Misc.Nitter.Title" = "Third-party Twitter data provider"; -"Scene.Settings.Misc.Proxy.Enable.Description" = "Use proxy for all network requests"; -"Scene.Settings.Misc.Proxy.Enable.Title" = "Proxy"; -"Scene.Settings.Misc.Proxy.Password" = "Password"; -"Scene.Settings.Misc.Proxy.Port.Error" = "Proxy server port must be numbers"; +"Scene.Settings.Misc.Nitter.Dialog.Usage.Title" = "Emprant proveïdors de dades de tercers a"; +"Scene.Settings.Misc.Nitter.Input.Description" = "Client alternatiu de Twitter centrat en la privacitat."; +"Scene.Settings.Misc.Nitter.Input.Invalid" = "La URL de la instància de Nitter no és vàlida, e.g.: https://nitter.net"; +"Scene.Settings.Misc.Nitter.Input.Placeholder" = "Instància de Nitter"; +"Scene.Settings.Misc.Nitter.Input.Value" = "URL de la instància"; +"Scene.Settings.Misc.Nitter.Title" = "Proveïdors de dades de tercers per Twuitter"; +"Scene.Settings.Misc.Proxy.Enable.Description" = "Empra aquest intermediari per totes les sol·licituds de xarxa"; +"Scene.Settings.Misc.Proxy.Enable.Title" = "Servidor intermedi"; +"Scene.Settings.Misc.Proxy.Password" = "Contrasenya"; +"Scene.Settings.Misc.Proxy.Port.Error" = "El port del servidor intermediari ha de ser numèric"; "Scene.Settings.Misc.Proxy.Port.Title" = "Port"; -"Scene.Settings.Misc.Proxy.Server" = "Server"; -"Scene.Settings.Misc.Proxy.Title" = "Proxy settings"; +"Scene.Settings.Misc.Proxy.Server" = "Servidor "; +"Scene.Settings.Misc.Proxy.Title" = "Configuració del servidor intermediari"; "Scene.Settings.Misc.Proxy.Type.Http" = "HTTP"; -"Scene.Settings.Misc.Proxy.Type.Reverse" = "Reverse"; +"Scene.Settings.Misc.Proxy.Type.Reverse" = "Inverteix"; "Scene.Settings.Misc.Proxy.Type.Socks" = "SOCKS"; -"Scene.Settings.Misc.Proxy.Type.Title" = "Proxy type"; -"Scene.Settings.Misc.Proxy.Username" = "Username"; -"Scene.Settings.Misc.Title" = "Misc"; +"Scene.Settings.Misc.Proxy.Type.Title" = "Tipus de servidor intermediari"; +"Scene.Settings.Misc.Proxy.Username" = "Nom d'usuari"; +"Scene.Settings.Misc.Title" = "Misceŀlània"; "Scene.Settings.Notification.Accounts" = "Comptes"; -"Scene.Settings.Notification.Mastodon.Favorite" = "Favorite"; -"Scene.Settings.Notification.Mastodon.Mention" = "Mention"; -"Scene.Settings.Notification.Mastodon.NewFollow" = "New Follow"; -"Scene.Settings.Notification.Mastodon.Poll" = "poll"; -"Scene.Settings.Notification.Mastodon.Reblog" = "Reblog"; -"Scene.Settings.Notification.NotificationSwitch" = "Show Notification"; -"Scene.Settings.Notification.PushNotification" = "Push Notification"; +"Scene.Settings.Notification.Mastodon.Favorite" = "Favorit"; +"Scene.Settings.Notification.Mastodon.Mention" = "Menció"; +"Scene.Settings.Notification.Mastodon.NewFollow" = "Nou seguidor"; +"Scene.Settings.Notification.Mastodon.Poll" = "enquesta"; +"Scene.Settings.Notification.Mastodon.Reblog" = "Rebloga"; +"Scene.Settings.Notification.NotificationSwitch" = "Mostra notificació"; +"Scene.Settings.Notification.PushNotification" = "Notificació «push»"; "Scene.Settings.Notification.Title" = "Notificació"; +"Scene.Settings.PrivacyAndSafety.AlwaysShowSensitiveMedia" = "Always show sensitive media"; +"Scene.Settings.PrivacyAndSafety.SectionHeader.MuteAndBlock" = "Silencia i bloca"; +"Scene.Settings.PrivacyAndSafety.SectionHeader.Sensitive" = "Sensitive Info"; +"Scene.Settings.PrivacyAndSafety.Title" = "Privacy and safety"; "Scene.Settings.SectionHeader.About" = "Quant a"; -"Scene.Settings.SectionHeader.Account" = "Account"; +"Scene.Settings.SectionHeader.Account" = "Compte"; "Scene.Settings.SectionHeader.General" = "General"; -"Scene.Settings.Storage.All.SubTitle" = "Delete all Twidere X cache. Your account credentials will not be lost."; -"Scene.Settings.Storage.All.Title" = "Clear all cache"; -"Scene.Settings.Storage.Media.SubTitle" = "Clear stored media cache."; -"Scene.Settings.Storage.Media.Title" = "Clear media cache"; -"Scene.Settings.Storage.Search.Title" = "Clear search history"; -"Scene.Settings.Storage.Title" = "Storage"; +"Scene.Settings.Storage.All.SubTitle" = "Esborra tota la memòria cau de Twidere X. Les vostres credencials del compte no es perdran."; +"Scene.Settings.Storage.All.Title" = "Esborra tota la memòria cau"; +"Scene.Settings.Storage.Media.SubTitle" = "Esborra la memòria cau de fitxers multimèdia."; +"Scene.Settings.Storage.Media.Title" = "Esborra la memòria cau multimèdia"; +"Scene.Settings.Storage.Search.Title" = "Esborra l'historial de cerca"; +"Scene.Settings.Storage.Title" = "Emmagatzematge"; "Scene.Settings.Title" = "Configuració"; "Scene.SignIn.HelloSignInToGetStarted" = "Hola! Inicia sessió per començar."; @@ -506,11 +548,11 @@ Inicia sessió per començar."; "Scene.Status.Retweet.Mutiple" = "%d repiulades"; "Scene.Status.Retweet.Single" = "1 repiulada"; "Scene.Status.Title" = "Piulada"; -"Scene.Status.TitleMastodon" = "Toot"; -"Scene.Timeline.Title" = "Línia de temps"; -"Scene.Trends.Accounts" = "%d people talking"; -"Scene.Trends.Now" = "Trending Now"; +"Scene.Status.TitleMastodon" = "Tut"; +"Scene.Timeline.Title" = "Cronologia"; +"Scene.Trends.Accounts" = "%d persones estan parlant"; +"Scene.Trends.Now" = "Ara és tendència"; "Scene.Trends.Title" = "Tendències"; -"Scene.Trends.TrendsLocation" = "Trends Location"; -"Scene.Trends.WorldWide" = "Trends - Worldwide"; -"Scene.Trends.WorldWideWithoutPrefix" = "Worldwide"; \ No newline at end of file +"Scene.Trends.TrendsLocation" = "Ubicació de les tendències"; +"Scene.Trends.WorldWide" = "Tendències - Mundials"; +"Scene.Trends.WorldWideWithoutPrefix" = "Mundial"; \ No newline at end of file diff --git a/TwidereSDK/Sources/TwidereLocalization/Resources/de.lproj/Localizable.strings b/TwidereSDK/Sources/TwidereLocalization/Resources/de.lproj/Localizable.strings index bbdd38bd..712a7a96 100644 --- a/TwidereSDK/Sources/TwidereLocalization/Resources/de.lproj/Localizable.strings +++ b/TwidereSDK/Sources/TwidereLocalization/Resources/de.lproj/Localizable.strings @@ -133,6 +133,8 @@ "Common.Alerts.RateLimitExceeded.Title" = "Kappungsgrenze überschritten"; "Common.Alerts.ReportAndBlockUserSuccess.Title" = "%@ wurde wegen Spam gemeldet und blockiert"; "Common.Alerts.ReportUserSuccess.Title" = "%@ wurde wegen Spam gemeldet"; +"Common.Alerts.RequestThrottle.Message" = "Operation too frequent. Please try again later"; +"Common.Alerts.RequestThrottle.Title" = "Request Throttle"; "Common.Alerts.SignOutUserConfirm.Message" = "Möchtest du dich abmelden?"; "Common.Alerts.SignOutUserConfirm.Title" = "Abmelden"; "Common.Alerts.TooManyRequests.Title" = "Zu viele Anfragen"; @@ -179,6 +181,8 @@ "Common.Controls.Actions.SignOut" = "Abmelden"; "Common.Controls.Actions.TakePhoto" = "Foto aufnehmen"; "Common.Controls.Actions.Yes" = "Ja"; +"Common.Controls.EmptyState.NoResults" = "Keine Ergebnisse"; +"Common.Controls.EmptyState.UnableToAccess" = "Unable to access"; "Common.Controls.Friendship.Actions.Block" = "Blockieren"; "Common.Controls.Friendship.Actions.Blocked" = "Blockiert"; "Common.Controls.Friendship.Actions.Follow" = "Folgen"; @@ -192,7 +196,7 @@ "Common.Controls.Friendship.Actions.Unfollow" = "Entfolgen"; "Common.Controls.Friendship.Actions.Unmute" = "Stummschaltung aufheben"; "Common.Controls.Friendship.BlockUser" = "%@ blockieren"; -"Common.Controls.Friendship.DoYouWantToReportAnd BlockUser" = "Do you want to report and block %@"; +"Common.Controls.Friendship.DoYouWantToReportAndBlockUser" = "Do you want to report and block %@"; "Common.Controls.Friendship.DoYouWantToReportUser" = "Do you want to report %@"; "Common.Controls.Friendship.Follower" = "folgender"; "Common.Controls.Friendship.Followers" = "folgende"; @@ -210,14 +214,20 @@ "Common.Controls.Status.Actions.CopyLink" = "Link kopieren"; "Common.Controls.Status.Actions.CopyText" = "Text kopieren"; "Common.Controls.Status.Actions.DeleteTweet" = "Tweet löschen"; +"Common.Controls.Status.Actions.Like" = "Gefällt mir"; "Common.Controls.Status.Actions.PinOnProfile" = "Profil anheften"; "Common.Controls.Status.Actions.Quote" = "Zitieren"; +"Common.Controls.Status.Actions.Reply" = "Antworten"; +"Common.Controls.Status.Actions.Repost" = "Repost"; "Common.Controls.Status.Actions.Retweet" = "Retweet"; "Common.Controls.Status.Actions.SaveMedia" = "Datei speichern"; "Common.Controls.Status.Actions.Share" = "Teilen"; "Common.Controls.Status.Actions.ShareContent" = "Inhalt teilen"; "Common.Controls.Status.Actions.ShareLink" = "Link teilen"; "Common.Controls.Status.Actions.Translate" = "Übersetzen"; +"Common.Controls.Status.Actions.UndoBoost" = "Undo Boost"; +"Common.Controls.Status.Actions.UndoRepost" = "Undo Repost"; +"Common.Controls.Status.Actions.UndoRetweet" = "Undo Retweet"; "Common.Controls.Status.Actions.UnpinFromProfile" = "Vom Profil lösen"; "Common.Controls.Status.Actions.Vote" = "Abstimmung"; "Common.Controls.Status.Media" = "Medien"; @@ -274,6 +284,11 @@ "Common.NotificationChannel.ContentMessages.Name" = "Nachrichten"; "Scene.Authentication.Title" = "Authentifizierung"; "Scene.Bookmark.Title" = "Lesezeichen"; +"Scene.Column.Actions.CloseColumn" = "Close column"; +"Scene.Column.Actions.MoveLeft" = "Move left"; +"Scene.Column.Actions.MoveRight" = "Move right"; +"Scene.Column.Actions.OpenInNewColumn" = "Open in new column"; +"Scene.Column.Title" = "New Column"; "Scene.Compose.And" = ", "; "Scene.Compose.CwPlaceholder" = "Write your warning here"; "Scene.Compose.LastEnd" = " und "; @@ -285,7 +300,7 @@ "Scene.Compose.Media.Remove" = "Entfernen"; "Scene.Compose.OthersInThisConversation" = "Andere in dieser Konversation:"; "Scene.Compose.Placeholder" = "Was ist passiert?"; -"Scene.Compose.ReplySettings.EveryoneCanReply" = "Everyone can peply"; +"Scene.Compose.ReplySettings.EveryoneCanReply" = "Everyone can reply"; "Scene.Compose.ReplySettings.OnlyPeopleYouMentionCanReply" = "Only people you mention can reply"; "Scene.Compose.ReplySettings.PeopleYouFollowCanReply" = "People you follow can reply"; "Scene.Compose.ReplyTo" = "Antworten auf …"; @@ -314,6 +329,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"; @@ -322,6 +338,11 @@ "Scene.Federated.Title" = "Federated"; "Scene.Followers.Title" = "Follower"; "Scene.Following.Title" = "Folgen"; +"Scene.History.Clear" = "Löschen"; +"Scene.History.Scope.Toot" = "Toot"; +"Scene.History.Scope.Tweet" = "Tweet"; +"Scene.History.Scope.User" = "User"; +"Scene.History.Title" = "Verlauf"; "Scene.Likes.Title" = "Likes"; "Scene.Listed.Title" = "Listet"; "Scene.Lists.Icons.Create" = "Create list"; @@ -360,6 +381,7 @@ "Scene.ListsUsers.MenuActions.Remove" = "Entfernen"; "Scene.Local.Title" = "Lokal"; "Scene.ManageAccounts.DeleteAccount" = "Konto löschen"; +"Scene.ManageAccounts.OpenInNewWindow" = "Open in new window"; "Scene.ManageAccounts.Title" = "Konten"; "Scene.Mentions.Title" = "Erwähnungen"; "Scene.Messages.Action.CopyText" = "Nachrichtentext kopieren"; @@ -398,7 +420,6 @@ Still in early stage."; "Scene.Settings.About.Logo.BackgroundShadow" = "About page background logo shadow"; "Scene.Settings.About.Title" = "Über"; "Scene.Settings.About.Version" = "Ver %@"; -"Scene.Settings.Account.AccountSettings" = "Account Settings"; "Scene.Settings.Account.BlockedPeople" = "Blocked People"; "Scene.Settings.Account.MuteAndBlock" = "Mute and Block"; "Scene.Settings.Account.MutedPeople" = "Muted People"; @@ -425,6 +446,22 @@ Still in early stage."; "Scene.Settings.Appearance.Translation.Off" = "Aus"; "Scene.Settings.Appearance.Translation.Service" = "Dienste"; "Scene.Settings.Appearance.Translation.TranslateButton" = "Übersetzen-Schaltfläche"; +"Scene.Settings.Behaviors.HistorySection.EnableHistoryRecord" = "Enable History Record"; +"Scene.Settings.Behaviors.HistorySection.History" = "Verlauf"; +"Scene.Settings.Behaviors.TabBarSection.ShowTabBarLabels" = "Show tab bar labels"; +"Scene.Settings.Behaviors.TabBarSection.TabBar" = "Tab Bar"; +"Scene.Settings.Behaviors.TabBarSection.TapTabBarScrollToTop" = "Tap tab bar scroll to top"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.AutomaticallyRefreshTimeline" = "Automatically refresh timeline"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshInterval" = "Refresh interval"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.120Seconds" = "120 seconds"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.300Seconds" = "300 seconds"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.30Seconds" = "30 seconds"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.60Seconds" = "60 seconds"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTop" = "Reset to top"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTopOption.DoubleTap" = "Double Tap"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTopOption.SingleTap" = "Single Tap"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.TimelineRefreshing" = "Timeline Refreshing"; +"Scene.Settings.Behaviors.Title" = "Behaviors"; "Scene.Settings.Display.DateFormat.Absolute" = "Absolut"; "Scene.Settings.Display.DateFormat.Relative" = "Relativ"; "Scene.Settings.Display.Media.Always" = "Immer"; @@ -434,6 +471,7 @@ Still in early stage."; "Scene.Settings.Display.Media.MuteByDefault" = "Standardmäßig stummschalten"; "Scene.Settings.Display.Media.Off" = "Aus"; "Scene.Settings.Display.Preview.ThankForUsingTwidereX" = "Vielen Dank für die Verwendung von @TwidereProject!"; +"Scene.Settings.Display.SectionHeader.Avatar" = "Avatar"; "Scene.Settings.Display.SectionHeader.DateFormat" = "Datumsformat"; "Scene.Settings.Display.SectionHeader.Media" = "Medien"; "Scene.Settings.Display.SectionHeader.Preview" = "Vorschau"; @@ -481,6 +519,10 @@ Still in early stage."; "Scene.Settings.Notification.NotificationSwitch" = "Benachrichtigung anzeigen"; "Scene.Settings.Notification.PushNotification" = "Push Notification"; "Scene.Settings.Notification.Title" = "Benachrichtigung"; +"Scene.Settings.PrivacyAndSafety.AlwaysShowSensitiveMedia" = "Always show sensitive media"; +"Scene.Settings.PrivacyAndSafety.SectionHeader.MuteAndBlock" = "Mute and Block"; +"Scene.Settings.PrivacyAndSafety.SectionHeader.Sensitive" = "Sensitive Info"; +"Scene.Settings.PrivacyAndSafety.Title" = "Privacy and safety"; "Scene.Settings.SectionHeader.About" = "Über"; "Scene.Settings.SectionHeader.Account" = "Konto"; "Scene.Settings.SectionHeader.General" = "Allgemein"; diff --git a/TwidereSDK/Sources/TwidereLocalization/Resources/en.lproj/Localizable.strings b/TwidereSDK/Sources/TwidereLocalization/Resources/en.lproj/Localizable.strings index fc3fa357..50502515 100644 --- a/TwidereSDK/Sources/TwidereLocalization/Resources/en.lproj/Localizable.strings +++ b/TwidereSDK/Sources/TwidereLocalization/Resources/en.lproj/Localizable.strings @@ -133,6 +133,8 @@ "Common.Alerts.RateLimitExceeded.Title" = "Rate Limit Exceeded"; "Common.Alerts.ReportAndBlockUserSuccess.Title" = "%@ has been reported for spam and blocked"; "Common.Alerts.ReportUserSuccess.Title" = "%@ has been reported for spam"; +"Common.Alerts.RequestThrottle.Message" = "Operation too frequent. Please try again later"; +"Common.Alerts.RequestThrottle.Title" = "Request Throttle"; "Common.Alerts.SignOutUserConfirm.Message" = "Do you want to sign out?"; "Common.Alerts.SignOutUserConfirm.Title" = "Sign out"; "Common.Alerts.TooManyRequests.Title" = "Too Many Requests"; @@ -179,6 +181,8 @@ "Common.Controls.Actions.SignOut" = "Sign out"; "Common.Controls.Actions.TakePhoto" = "Take photo"; "Common.Controls.Actions.Yes" = "Yes"; +"Common.Controls.EmptyState.NoResults" = "No results"; +"Common.Controls.EmptyState.UnableToAccess" = "Unable to access"; "Common.Controls.Friendship.Actions.Block" = "Block"; "Common.Controls.Friendship.Actions.Blocked" = "Blocked"; "Common.Controls.Friendship.Actions.Follow" = "Follow"; @@ -192,7 +196,7 @@ "Common.Controls.Friendship.Actions.Unfollow" = "Unfollow"; "Common.Controls.Friendship.Actions.Unmute" = "Unmute"; "Common.Controls.Friendship.BlockUser" = "Block %@"; -"Common.Controls.Friendship.DoYouWantToReportAnd BlockUser" = "Do you want to report and block %@"; +"Common.Controls.Friendship.DoYouWantToReportAndBlockUser" = "Do you want to report and block %@"; "Common.Controls.Friendship.DoYouWantToReportUser" = "Do you want to report %@"; "Common.Controls.Friendship.Follower" = "follower"; "Common.Controls.Friendship.Followers" = "followers"; @@ -210,14 +214,20 @@ "Common.Controls.Status.Actions.CopyLink" = "Copy link"; "Common.Controls.Status.Actions.CopyText" = "Copy text"; "Common.Controls.Status.Actions.DeleteTweet" = "Delete tweet"; +"Common.Controls.Status.Actions.Like" = "Like"; "Common.Controls.Status.Actions.PinOnProfile" = "Pin on Profile"; "Common.Controls.Status.Actions.Quote" = "Quote"; +"Common.Controls.Status.Actions.Reply" = "Reply"; +"Common.Controls.Status.Actions.Repost" = "Repost"; "Common.Controls.Status.Actions.Retweet" = "Retweet"; "Common.Controls.Status.Actions.SaveMedia" = "Save media"; "Common.Controls.Status.Actions.Share" = "Share"; "Common.Controls.Status.Actions.ShareContent" = "Share content"; "Common.Controls.Status.Actions.ShareLink" = "Share link"; "Common.Controls.Status.Actions.Translate" = "Translate"; +"Common.Controls.Status.Actions.UndoBoost" = "Undo Boost"; +"Common.Controls.Status.Actions.UndoRepost" = "Undo Repost"; +"Common.Controls.Status.Actions.UndoRetweet" = "Undo Retweet"; "Common.Controls.Status.Actions.UnpinFromProfile" = "Unpin from Profile"; "Common.Controls.Status.Actions.Vote" = "Vote"; "Common.Controls.Status.Media" = "Media"; @@ -274,6 +284,11 @@ "Common.NotificationChannel.ContentMessages.Name" = "Messages"; "Scene.Authentication.Title" = "Authentication"; "Scene.Bookmark.Title" = "Bookmark"; +"Scene.Column.Actions.CloseColumn" = "Close column"; +"Scene.Column.Actions.MoveLeft" = "Move left"; +"Scene.Column.Actions.MoveRight" = "Move right"; +"Scene.Column.Actions.OpenInNewColumn" = "Open in new column"; +"Scene.Column.Title" = "New Column"; "Scene.Compose.And" = ", "; "Scene.Compose.CwPlaceholder" = "Write your warning here"; "Scene.Compose.LastEnd" = " and "; @@ -285,7 +300,7 @@ "Scene.Compose.Media.Remove" = "Remove"; "Scene.Compose.OthersInThisConversation" = "Others in this conversation:"; "Scene.Compose.Placeholder" = "What’s happening?"; -"Scene.Compose.ReplySettings.EveryoneCanReply" = "Everyone can peply"; +"Scene.Compose.ReplySettings.EveryoneCanReply" = "Everyone can reply"; "Scene.Compose.ReplySettings.OnlyPeopleYouMentionCanReply" = "Only people you mention can reply"; "Scene.Compose.ReplySettings.PeopleYouFollowCanReply" = "People you follow can reply"; "Scene.Compose.ReplyTo" = "Reply to …"; @@ -314,6 +329,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"; @@ -322,6 +338,11 @@ "Scene.Federated.Title" = "Federated"; "Scene.Followers.Title" = "Followers"; "Scene.Following.Title" = "Following"; +"Scene.History.Clear" = "Clear"; +"Scene.History.Scope.Toot" = "Toot"; +"Scene.History.Scope.Tweet" = "Tweet"; +"Scene.History.Scope.User" = "User"; +"Scene.History.Title" = "History"; "Scene.Likes.Title" = "Likes"; "Scene.Listed.Title" = "Listed"; "Scene.Lists.Icons.Create" = "Create list"; @@ -360,6 +381,7 @@ "Scene.ListsUsers.MenuActions.Remove" = "Remove"; "Scene.Local.Title" = "Local"; "Scene.ManageAccounts.DeleteAccount" = "Delete account"; +"Scene.ManageAccounts.OpenInNewWindow" = "Open in new window"; "Scene.ManageAccounts.Title" = "Accounts"; "Scene.Mentions.Title" = "Mentions"; "Scene.Messages.Action.CopyText" = "Copy message text"; @@ -398,7 +420,6 @@ Still in early stage."; "Scene.Settings.About.Logo.BackgroundShadow" = "About page background logo shadow"; "Scene.Settings.About.Title" = "About"; "Scene.Settings.About.Version" = "Ver %@"; -"Scene.Settings.Account.AccountSettings" = "Account Settings"; "Scene.Settings.Account.BlockedPeople" = "Blocked People"; "Scene.Settings.Account.MuteAndBlock" = "Mute and Block"; "Scene.Settings.Account.MutedPeople" = "Muted People"; @@ -425,6 +446,22 @@ Still in early stage."; "Scene.Settings.Appearance.Translation.Off" = "Off"; "Scene.Settings.Appearance.Translation.Service" = "Service"; "Scene.Settings.Appearance.Translation.TranslateButton" = "Translate button"; +"Scene.Settings.Behaviors.HistorySection.EnableHistoryRecord" = "Enable History Record"; +"Scene.Settings.Behaviors.HistorySection.History" = "History"; +"Scene.Settings.Behaviors.TabBarSection.ShowTabBarLabels" = "Show tab bar labels"; +"Scene.Settings.Behaviors.TabBarSection.TabBar" = "Tab Bar"; +"Scene.Settings.Behaviors.TabBarSection.TapTabBarScrollToTop" = "Tap tab bar scroll to top"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.AutomaticallyRefreshTimeline" = "Automatically refresh timeline"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshInterval" = "Refresh interval"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.120Seconds" = "120 seconds"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.300Seconds" = "300 seconds"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.30Seconds" = "30 seconds"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.60Seconds" = "60 seconds"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTop" = "Reset to top"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTopOption.DoubleTap" = "Double Tap"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTopOption.SingleTap" = "Single Tap"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.TimelineRefreshing" = "Timeline Refreshing"; +"Scene.Settings.Behaviors.Title" = "Behaviors"; "Scene.Settings.Display.DateFormat.Absolute" = "Absolute"; "Scene.Settings.Display.DateFormat.Relative" = "Relative"; "Scene.Settings.Display.Media.Always" = "Always"; @@ -434,6 +471,7 @@ Still in early stage."; "Scene.Settings.Display.Media.MuteByDefault" = "Mute by default"; "Scene.Settings.Display.Media.Off" = "Off"; "Scene.Settings.Display.Preview.ThankForUsingTwidereX" = "Thanks for using @TwidereProject!"; +"Scene.Settings.Display.SectionHeader.Avatar" = "Avatar"; "Scene.Settings.Display.SectionHeader.DateFormat" = "Date Format"; "Scene.Settings.Display.SectionHeader.Media" = "Media"; "Scene.Settings.Display.SectionHeader.Preview" = "Preview"; @@ -481,6 +519,10 @@ Still in early stage."; "Scene.Settings.Notification.NotificationSwitch" = "Show Notification"; "Scene.Settings.Notification.PushNotification" = "Push Notification"; "Scene.Settings.Notification.Title" = "Notification"; +"Scene.Settings.PrivacyAndSafety.AlwaysShowSensitiveMedia" = "Always show sensitive media"; +"Scene.Settings.PrivacyAndSafety.SectionHeader.MuteAndBlock" = "Mute and Block"; +"Scene.Settings.PrivacyAndSafety.SectionHeader.Sensitive" = "Sensitive Info"; +"Scene.Settings.PrivacyAndSafety.Title" = "Privacy and safety"; "Scene.Settings.SectionHeader.About" = "About"; "Scene.Settings.SectionHeader.Account" = "Account"; "Scene.Settings.SectionHeader.General" = "General"; diff --git a/TwidereSDK/Sources/TwidereLocalization/Resources/es.lproj/Localizable.strings b/TwidereSDK/Sources/TwidereLocalization/Resources/es.lproj/Localizable.strings index 7073335d..b46f04b5 100644 --- a/TwidereSDK/Sources/TwidereLocalization/Resources/es.lproj/Localizable.strings +++ b/TwidereSDK/Sources/TwidereLocalization/Resources/es.lproj/Localizable.strings @@ -128,11 +128,13 @@ "Common.Alerts.PhotoSaveFail.Title" = "Error al guardar la foto"; "Common.Alerts.PhotoSaved.Title" = "Foto guardada"; "Common.Alerts.PostFailInvalidPoll.Message" = "Poll has empty field. Please fulfill the field then try again"; -"Common.Alerts.PostFailInvalidPoll.Title" = "Failed to Publish"; +"Common.Alerts.PostFailInvalidPoll.Title" = "Fallo al publicar"; "Common.Alerts.RateLimitExceeded.Message" = "Límite de uso de la API de Twitter alcanzado"; "Common.Alerts.RateLimitExceeded.Title" = "Límite de transferencia excedido"; "Common.Alerts.ReportAndBlockUserSuccess.Title" = "%@ ha sido reportado por spam y bloqueado"; "Common.Alerts.ReportUserSuccess.Title" = "%@ ha sido reportado por spam"; +"Common.Alerts.RequestThrottle.Message" = "Operation too frequent. Please try again later"; +"Common.Alerts.RequestThrottle.Title" = "Reducir aceleración"; "Common.Alerts.SignOutUserConfirm.Message" = "¿Desea cerrar sesión?"; "Common.Alerts.SignOutUserConfirm.Title" = "Cerrar sesión"; "Common.Alerts.TooManyRequests.Title" = "Demasiadas solicitudes"; @@ -158,7 +160,7 @@ "Common.Controls.Actions.Add" = "Añadir"; "Common.Controls.Actions.Browse" = "Navegar"; "Common.Controls.Actions.Cancel" = "Cancelar"; -"Common.Controls.Actions.Clear" = "Clear"; +"Common.Controls.Actions.Clear" = "Eliminar"; "Common.Controls.Actions.Confirm" = "Confirmar"; "Common.Controls.Actions.Copy" = "Copiar"; "Common.Controls.Actions.Delete" = "Eliminar"; @@ -179,6 +181,8 @@ "Common.Controls.Actions.SignOut" = "Cerrar sesión"; "Common.Controls.Actions.TakePhoto" = "Hacer una foto"; "Common.Controls.Actions.Yes" = "Sí"; +"Common.Controls.EmptyState.NoResults" = "Sin resultados"; +"Common.Controls.EmptyState.UnableToAccess" = "Unable to access"; "Common.Controls.Friendship.Actions.Block" = "Bloquear"; "Common.Controls.Friendship.Actions.Blocked" = "Bloqueado"; "Common.Controls.Friendship.Actions.Follow" = "Seguir"; @@ -192,7 +196,7 @@ "Common.Controls.Friendship.Actions.Unfollow" = "Dejar de seguir"; "Common.Controls.Friendship.Actions.Unmute" = "No Silenciar"; "Common.Controls.Friendship.BlockUser" = "Bloquear a %@"; -"Common.Controls.Friendship.DoYouWantToReportAnd BlockUser" = "Do you want to report and block %@"; +"Common.Controls.Friendship.DoYouWantToReportAndBlockUser" = "Do you want to report and block %@"; "Common.Controls.Friendship.DoYouWantToReportUser" = "Do you want to report %@"; "Common.Controls.Friendship.Follower" = "seguidor"; "Common.Controls.Friendship.Followers" = "seguidores"; @@ -210,14 +214,20 @@ "Common.Controls.Status.Actions.CopyLink" = "Copiar enlace"; "Common.Controls.Status.Actions.CopyText" = "Copiar texto"; "Common.Controls.Status.Actions.DeleteTweet" = "Eliminar tweet"; +"Common.Controls.Status.Actions.Like" = "Me gusta"; "Common.Controls.Status.Actions.PinOnProfile" = "Fijar en el perfil"; "Common.Controls.Status.Actions.Quote" = "Cita"; +"Common.Controls.Status.Actions.Reply" = "Responder"; +"Common.Controls.Status.Actions.Repost" = "Repost"; "Common.Controls.Status.Actions.Retweet" = "Retwittear"; "Common.Controls.Status.Actions.SaveMedia" = "Guardar multimedia"; "Common.Controls.Status.Actions.Share" = "Compartir"; "Common.Controls.Status.Actions.ShareContent" = "Compartir contenido"; "Common.Controls.Status.Actions.ShareLink" = "Compartir enlace"; "Common.Controls.Status.Actions.Translate" = "Traducir"; +"Common.Controls.Status.Actions.UndoBoost" = "Undo Boost"; +"Common.Controls.Status.Actions.UndoRepost" = "Undo Repost"; +"Common.Controls.Status.Actions.UndoRetweet" = "Undo Retweet"; "Common.Controls.Status.Actions.UnpinFromProfile" = "Desfijar del perfil"; "Common.Controls.Status.Actions.Vote" = "Votar"; "Common.Controls.Status.Media" = "Multimedia"; @@ -226,8 +236,8 @@ "Common.Controls.Status.Poll.TotalPerson" = "%@ persona"; "Common.Controls.Status.Poll.TotalVote" = "%@ voto"; "Common.Controls.Status.Poll.TotalVotes" = "%@ votos"; -"Common.Controls.Status.ReplySettings.PeopleUserFollowsOrMentionedCanReply" = "People %@ follows or mentioned can reply."; -"Common.Controls.Status.ReplySettings.PeopleUserMentionedCanReply" = "People %@ mentioned can reply."; +"Common.Controls.Status.ReplySettings.PeopleUserFollowsOrMentionedCanReply" = "Las personas que %@ siguen o mencionadas pueden responder."; +"Common.Controls.Status.ReplySettings.PeopleUserMentionedCanReply" = "Los usuarios %@ mencionados pueden responder."; "Common.Controls.Status.Thread.Show" = "Mostrar este hilo de conversación"; "Common.Controls.Status.UserBoosted" = "%@ retooteó"; "Common.Controls.Status.UserRetweeted" = "%@ ha retwitteado"; @@ -256,8 +266,8 @@ "Common.Notification.Favourite" = "%@ ha marcado como favorito tu toot"; "Common.Notification.Follow" = "%@ te ha seguido"; "Common.Notification.FollowRequest" = "%@ ha solicitado seguirte"; -"Common.Notification.FollowRequestAction.Approve" = "Approve"; -"Common.Notification.FollowRequestAction.Deny" = "Deny"; +"Common.Notification.FollowRequestAction.Approve" = "Aprobar"; +"Common.Notification.FollowRequestAction.Deny" = "Denegar"; "Common.Notification.FollowRequestResponse.FollowRequestApproved" = "Follow Request Approved"; "Common.Notification.FollowRequestResponse.FollowRequestDenied" = "Follow Request Denied"; "Common.Notification.Mentions" = "%@ te ha mencionado"; @@ -274,6 +284,11 @@ "Common.NotificationChannel.ContentMessages.Name" = "Mensajes"; "Scene.Authentication.Title" = "Autentificación"; "Scene.Bookmark.Title" = "Marcador"; +"Scene.Column.Actions.CloseColumn" = "Close column"; +"Scene.Column.Actions.MoveLeft" = "Move left"; +"Scene.Column.Actions.MoveRight" = "Move right"; +"Scene.Column.Actions.OpenInNewColumn" = "Open in new column"; +"Scene.Column.Title" = "New Column"; "Scene.Compose.And" = ", "; "Scene.Compose.CwPlaceholder" = "Escribe tu advertencia aquí"; "Scene.Compose.LastEnd" = " y "; @@ -285,7 +300,7 @@ "Scene.Compose.Media.Remove" = "Eliminar"; "Scene.Compose.OthersInThisConversation" = "Usuarios en esta conversación:"; "Scene.Compose.Placeholder" = "¿Qué está pasando?"; -"Scene.Compose.ReplySettings.EveryoneCanReply" = "Everyone can peply"; +"Scene.Compose.ReplySettings.EveryoneCanReply" = "Everyone can reply"; "Scene.Compose.ReplySettings.OnlyPeopleYouMentionCanReply" = "Only people you mention can reply"; "Scene.Compose.ReplySettings.PeopleYouFollowCanReply" = "People you follow can reply"; "Scene.Compose.ReplyTo" = "Responder a …"; @@ -301,8 +316,8 @@ "Scene.Compose.Visibility.Unlisted" = "No listado"; "Scene.Compose.VisibilityDescription.Direct" = "Visible sólo para usuarios mencionados"; "Scene.Compose.VisibilityDescription.Private" = "Visible sólo para seguidores"; -"Scene.Compose.VisibilityDescription.Public" = "Visible para todos, se muestra en cronologías de inicio públicas"; -"Scene.Compose.VisibilityDescription.Unlisted" = "Visible para todos, pero no se muestra en cronologías de inicio públicas"; +"Scene.Compose.VisibilityDescription.Public" = "Visible para todos, se muestra en cronologías públicas"; +"Scene.Compose.VisibilityDescription.Unlisted" = "Visible para todos, pero no se muestra en cronologías públicas"; "Scene.Compose.Vote.Expiration.1Day" = "1 día"; "Scene.Compose.Vote.Expiration.1Hour" = "1 hora"; "Scene.Compose.Vote.Expiration.30Min" = "30 minutos"; @@ -314,6 +329,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"; @@ -322,6 +338,11 @@ "Scene.Federated.Title" = "Federada"; "Scene.Followers.Title" = "Seguidores"; "Scene.Following.Title" = "Siguiendo"; +"Scene.History.Clear" = "Eliminar"; +"Scene.History.Scope.Toot" = "Toot"; +"Scene.History.Scope.Tweet" = "Tweet"; +"Scene.History.Scope.User" = "User"; +"Scene.History.Title" = "Historial"; "Scene.Likes.Title" = "Me gusta"; "Scene.Listed.Title" = "Listado"; "Scene.Lists.Icons.Create" = "Crear lista"; @@ -360,6 +381,7 @@ "Scene.ListsUsers.MenuActions.Remove" = "Eliminar"; "Scene.Local.Title" = "Local"; "Scene.ManageAccounts.DeleteAccount" = "Eliminar cuenta"; +"Scene.ManageAccounts.OpenInNewWindow" = "Open in new window"; "Scene.ManageAccounts.Title" = "Cuentas"; "Scene.Mentions.Title" = "Menciones"; "Scene.Messages.Action.CopyText" = "Copiar texto del mensaje"; @@ -398,7 +420,6 @@ Todavía en fase temprana."; "Scene.Settings.About.Logo.BackgroundShadow" = "Acerca de la sombra del logo del fondo de la página"; "Scene.Settings.About.Title" = "Acerca de"; "Scene.Settings.About.Version" = "Ver %@"; -"Scene.Settings.Account.AccountSettings" = "Account Settings"; "Scene.Settings.Account.BlockedPeople" = "Blocked People"; "Scene.Settings.Account.MuteAndBlock" = "Mute and Block"; "Scene.Settings.Account.MutedPeople" = "Muted People"; @@ -410,7 +431,7 @@ Todavía en fase temprana."; "Scene.Settings.Appearance.ScrollingTimeline.AppBar" = "Ocultar barra de aplicación al desplazar"; "Scene.Settings.Appearance.ScrollingTimeline.Fab" = "Ocultar botón de acción flotante al desplazar"; "Scene.Settings.Appearance.ScrollingTimeline.TabBar" = "Ocultar barra de pestañas al desplazar"; -"Scene.Settings.Appearance.SectionHeader.ScrollingTimeline" = "Cronología de inicio desplazable"; +"Scene.Settings.Appearance.SectionHeader.ScrollingTimeline" = "Línea de tiempo desplazable"; "Scene.Settings.Appearance.SectionHeader.TabPosition" = "Posición de la pestaña"; "Scene.Settings.Appearance.SectionHeader.Theme" = "Tema"; "Scene.Settings.Appearance.SectionHeader.Translation" = "Traducción"; @@ -425,6 +446,22 @@ Todavía en fase temprana."; "Scene.Settings.Appearance.Translation.Off" = "Desactivado"; "Scene.Settings.Appearance.Translation.Service" = "Servicio"; "Scene.Settings.Appearance.Translation.TranslateButton" = "Botón de traducción"; +"Scene.Settings.Behaviors.HistorySection.EnableHistoryRecord" = "Enable History Record"; +"Scene.Settings.Behaviors.HistorySection.History" = "Historial"; +"Scene.Settings.Behaviors.TabBarSection.ShowTabBarLabels" = "Show tab bar labels"; +"Scene.Settings.Behaviors.TabBarSection.TabBar" = "Tab Bar"; +"Scene.Settings.Behaviors.TabBarSection.TapTabBarScrollToTop" = "Tap tab bar scroll to top"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.AutomaticallyRefreshTimeline" = "Automatically refresh timeline"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshInterval" = "Refresh interval"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.120Seconds" = "120 seconds"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.300Seconds" = "300 seconds"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.30Seconds" = "30 seconds"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.60Seconds" = "60 seconds"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTop" = "Reset to top"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTopOption.DoubleTap" = "Double Tap"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTopOption.SingleTap" = "Single Tap"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.TimelineRefreshing" = "Timeline Refreshing"; +"Scene.Settings.Behaviors.Title" = "Behaviors"; "Scene.Settings.Display.DateFormat.Absolute" = "Absoluto"; "Scene.Settings.Display.DateFormat.Relative" = "Relativo"; "Scene.Settings.Display.Media.Always" = "Siempre"; @@ -434,6 +471,7 @@ Todavía en fase temprana."; "Scene.Settings.Display.Media.MuteByDefault" = "Silenciar por defecto"; "Scene.Settings.Display.Media.Off" = "Desactivado"; "Scene.Settings.Display.Preview.ThankForUsingTwidereX" = "¡Gracias por usar @TwidereProject!"; +"Scene.Settings.Display.SectionHeader.Avatar" = "Avatar"; "Scene.Settings.Display.SectionHeader.DateFormat" = "Formato de fecha"; "Scene.Settings.Display.SectionHeader.Media" = "Multimedia"; "Scene.Settings.Display.SectionHeader.Preview" = "Vista previa"; @@ -481,6 +519,10 @@ Todavía en fase temprana."; "Scene.Settings.Notification.NotificationSwitch" = "Mostrar notificación"; "Scene.Settings.Notification.PushNotification" = "Push Notification"; "Scene.Settings.Notification.Title" = "Notificación"; +"Scene.Settings.PrivacyAndSafety.AlwaysShowSensitiveMedia" = "Always show sensitive media"; +"Scene.Settings.PrivacyAndSafety.SectionHeader.MuteAndBlock" = "Mute and Block"; +"Scene.Settings.PrivacyAndSafety.SectionHeader.Sensitive" = "Sensitive Info"; +"Scene.Settings.PrivacyAndSafety.Title" = "Privacy and safety"; "Scene.Settings.SectionHeader.About" = "Acerca de"; "Scene.Settings.SectionHeader.Account" = "Cuenta"; "Scene.Settings.SectionHeader.General" = "General"; @@ -507,7 +549,7 @@ Inicie sesión para empezar."; "Scene.Status.Retweet.Single" = "1 Retweet"; "Scene.Status.Title" = "Tweet"; "Scene.Status.TitleMastodon" = "Toot"; -"Scene.Timeline.Title" = "Cronología de inicio"; +"Scene.Timeline.Title" = "Inicio"; "Scene.Trends.Accounts" = "%d personas hablando"; "Scene.Trends.Now" = "Tendencias en este momento"; "Scene.Trends.Title" = "Tendencias"; diff --git a/TwidereSDK/Sources/TwidereLocalization/Resources/es.lproj/Localizable.stringsdict b/TwidereSDK/Sources/TwidereLocalization/Resources/es.lproj/Localizable.stringsdict index b4cf04f6..9fcf85d6 100644 --- a/TwidereSDK/Sources/TwidereLocalization/Resources/es.lproj/Localizable.stringsdict +++ b/TwidereSDK/Sources/TwidereLocalization/Resources/es.lproj/Localizable.stringsdict @@ -13,9 +13,9 @@ NSStringFormatValueTypeKey ld one - 1 notification + 1 notificación other - %ld notifications + %Id notificaciones count.media diff --git a/TwidereSDK/Sources/TwidereLocalization/Resources/eu.lproj/Localizable.strings b/TwidereSDK/Sources/TwidereLocalization/Resources/eu.lproj/Localizable.strings index 5437d511..88e03354 100644 --- a/TwidereSDK/Sources/TwidereLocalization/Resources/eu.lproj/Localizable.strings +++ b/TwidereSDK/Sources/TwidereLocalization/Resources/eu.lproj/Localizable.strings @@ -133,6 +133,8 @@ "Common.Alerts.RateLimitExceeded.Title" = "Tasaren muga gainditu da"; "Common.Alerts.ReportAndBlockUserSuccess.Title" = "%@ spam bidez salatu da eta blokeatu da"; "Common.Alerts.ReportUserSuccess.Title" = "%@ spam bidez salatu da"; +"Common.Alerts.RequestThrottle.Message" = "Operation too frequent. Please try again later"; +"Common.Alerts.RequestThrottle.Title" = "Request Throttle"; "Common.Alerts.SignOutUserConfirm.Message" = "Saiotik irten nahi duzu?"; "Common.Alerts.SignOutUserConfirm.Title" = "Amaitu saioa"; "Common.Alerts.TooManyRequests.Title" = "Eskaera gehiegi"; @@ -179,6 +181,8 @@ "Common.Controls.Actions.SignOut" = "Amaitu saioa"; "Common.Controls.Actions.TakePhoto" = "Atera argazkia"; "Common.Controls.Actions.Yes" = "Bai"; +"Common.Controls.EmptyState.NoResults" = "Emaitzarik ez"; +"Common.Controls.EmptyState.UnableToAccess" = "Unable to access"; "Common.Controls.Friendship.Actions.Block" = "Blokeatu"; "Common.Controls.Friendship.Actions.Blocked" = "Blokeatuta"; "Common.Controls.Friendship.Actions.Follow" = "Jarraitu"; @@ -192,7 +196,7 @@ "Common.Controls.Friendship.Actions.Unfollow" = "Utzi jarraitzeari"; "Common.Controls.Friendship.Actions.Unmute" = "Desmututu"; "Common.Controls.Friendship.BlockUser" = "%@ blokeatu"; -"Common.Controls.Friendship.DoYouWantToReportAnd BlockUser" = "Do you want to report and block %@"; +"Common.Controls.Friendship.DoYouWantToReportAndBlockUser" = "Do you want to report and block %@"; "Common.Controls.Friendship.DoYouWantToReportUser" = "Do you want to report %@"; "Common.Controls.Friendship.Follower" = "jarratzaile"; "Common.Controls.Friendship.Followers" = "jarratzaileak"; @@ -210,14 +214,20 @@ "Common.Controls.Status.Actions.CopyLink" = "Kopiatu esteka"; "Common.Controls.Status.Actions.CopyText" = "Kopiatu testua"; "Common.Controls.Status.Actions.DeleteTweet" = "Txioa ezabatu"; +"Common.Controls.Status.Actions.Like" = "Atsegin"; "Common.Controls.Status.Actions.PinOnProfile" = "Profilean finkatu"; "Common.Controls.Status.Actions.Quote" = "Aipatu"; +"Common.Controls.Status.Actions.Reply" = "Erantzun"; +"Common.Controls.Status.Actions.Repost" = "Repost"; "Common.Controls.Status.Actions.Retweet" = "Bertxiotu"; "Common.Controls.Status.Actions.SaveMedia" = "Save media"; "Common.Controls.Status.Actions.Share" = "Partekatu"; "Common.Controls.Status.Actions.ShareContent" = "Edukia partekatu"; "Common.Controls.Status.Actions.ShareLink" = "Partekatu esteka"; "Common.Controls.Status.Actions.Translate" = "Itzuli"; +"Common.Controls.Status.Actions.UndoBoost" = "Undo Boost"; +"Common.Controls.Status.Actions.UndoRepost" = "Undo Repost"; +"Common.Controls.Status.Actions.UndoRetweet" = "Undo Retweet"; "Common.Controls.Status.Actions.UnpinFromProfile" = "Profilatik finkatuta zegoena kendu"; "Common.Controls.Status.Actions.Vote" = "Botoa eman"; "Common.Controls.Status.Media" = "Multimedia"; @@ -274,6 +284,11 @@ "Common.NotificationChannel.ContentMessages.Name" = "Mezuak"; "Scene.Authentication.Title" = "Autentifikazioa"; "Scene.Bookmark.Title" = "Laster-markak"; +"Scene.Column.Actions.CloseColumn" = "Close column"; +"Scene.Column.Actions.MoveLeft" = "Move left"; +"Scene.Column.Actions.MoveRight" = "Move right"; +"Scene.Column.Actions.OpenInNewColumn" = "Open in new column"; +"Scene.Column.Title" = "New Column"; "Scene.Compose.And" = ", "; "Scene.Compose.CwPlaceholder" = "Idatz ezazu hemen zure ohartarazpena"; "Scene.Compose.LastEnd" = " eta "; @@ -285,7 +300,7 @@ "Scene.Compose.Media.Remove" = "Ezabatu"; "Scene.Compose.OthersInThisConversation" = "Beste batzuk elkarrizketa honetan:"; "Scene.Compose.Placeholder" = "Zer gertatzen ari da?"; -"Scene.Compose.ReplySettings.EveryoneCanReply" = "Everyone can peply"; +"Scene.Compose.ReplySettings.EveryoneCanReply" = "Everyone can reply"; "Scene.Compose.ReplySettings.OnlyPeopleYouMentionCanReply" = "Only people you mention can reply"; "Scene.Compose.ReplySettings.PeopleYouFollowCanReply" = "People you follow can reply"; "Scene.Compose.ReplyTo" = "… erantzun"; @@ -314,6 +329,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"; @@ -322,6 +338,11 @@ "Scene.Federated.Title" = "Federatua"; "Scene.Followers.Title" = "Jarratzaileak"; "Scene.Following.Title" = "Jarraitzen"; +"Scene.History.Clear" = "Clear"; +"Scene.History.Scope.Toot" = "Toota"; +"Scene.History.Scope.Tweet" = "Txiokatu"; +"Scene.History.Scope.User" = "User"; +"Scene.History.Title" = "Historia"; "Scene.Likes.Title" = "Atsegite"; "Scene.Listed.Title" = "Zerrendatua"; "Scene.Lists.Icons.Create" = "Sortu zerrenda"; @@ -360,6 +381,7 @@ "Scene.ListsUsers.MenuActions.Remove" = "Ezabatu"; "Scene.Local.Title" = "Lokala"; "Scene.ManageAccounts.DeleteAccount" = "Ezabatu kontua"; +"Scene.ManageAccounts.OpenInNewWindow" = "Open in new window"; "Scene.ManageAccounts.Title" = "Kontuak"; "Scene.Mentions.Title" = "Aipamenak"; "Scene.Messages.Action.CopyText" = "Mezuaren testua kopiatu"; @@ -398,7 +420,6 @@ Oraindik hasierako fasean dago."; "Scene.Settings.About.Logo.BackgroundShadow" = "Orrialdearen hondoko logotipoaren itzalari buruz"; "Scene.Settings.About.Title" = "Honi buruz"; "Scene.Settings.About.Version" = "%@ ikusi"; -"Scene.Settings.Account.AccountSettings" = "Account Settings"; "Scene.Settings.Account.BlockedPeople" = "Blocked People"; "Scene.Settings.Account.MuteAndBlock" = "Mute and Block"; "Scene.Settings.Account.MutedPeople" = "Muted People"; @@ -425,6 +446,22 @@ Oraindik hasierako fasean dago."; "Scene.Settings.Appearance.Translation.Off" = "Itzalita"; "Scene.Settings.Appearance.Translation.Service" = "Service"; "Scene.Settings.Appearance.Translation.TranslateButton" = "Translate button"; +"Scene.Settings.Behaviors.HistorySection.EnableHistoryRecord" = "Enable History Record"; +"Scene.Settings.Behaviors.HistorySection.History" = "Historia"; +"Scene.Settings.Behaviors.TabBarSection.ShowTabBarLabels" = "Show tab bar labels"; +"Scene.Settings.Behaviors.TabBarSection.TabBar" = "Tab Bar"; +"Scene.Settings.Behaviors.TabBarSection.TapTabBarScrollToTop" = "Tap tab bar scroll to top"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.AutomaticallyRefreshTimeline" = "Automatically refresh timeline"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshInterval" = "Refresh interval"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.120Seconds" = "120 seconds"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.300Seconds" = "300 seconds"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.30Seconds" = "30 seconds"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.60Seconds" = "60 seconds"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTop" = "Reset to top"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTopOption.DoubleTap" = "Double Tap"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTopOption.SingleTap" = "Single Tap"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.TimelineRefreshing" = "Timeline Refreshing"; +"Scene.Settings.Behaviors.Title" = "Behaviors"; "Scene.Settings.Display.DateFormat.Absolute" = "Absolutua"; "Scene.Settings.Display.DateFormat.Relative" = "Erlatiboa"; "Scene.Settings.Display.Media.Always" = "Beti"; @@ -434,6 +471,7 @@ Oraindik hasierako fasean dago."; "Scene.Settings.Display.Media.MuteByDefault" = "Isilarazi lehenetsita"; "Scene.Settings.Display.Media.Off" = "Itzalita"; "Scene.Settings.Display.Preview.ThankForUsingTwidereX" = "Eskerrik asko @TwidereProject erabiltzeagatik!"; +"Scene.Settings.Display.SectionHeader.Avatar" = "Avatar"; "Scene.Settings.Display.SectionHeader.DateFormat" = "Data-formatua"; "Scene.Settings.Display.SectionHeader.Media" = "Multimedia"; "Scene.Settings.Display.SectionHeader.Preview" = "Aurrebista"; @@ -481,6 +519,10 @@ Oraindik hasierako fasean dago."; "Scene.Settings.Notification.NotificationSwitch" = "Erakutsi jakinarazpena"; "Scene.Settings.Notification.PushNotification" = "Push Notification"; "Scene.Settings.Notification.Title" = "Jakinarazpena"; +"Scene.Settings.PrivacyAndSafety.AlwaysShowSensitiveMedia" = "Always show sensitive media"; +"Scene.Settings.PrivacyAndSafety.SectionHeader.MuteAndBlock" = "Mute and Block"; +"Scene.Settings.PrivacyAndSafety.SectionHeader.Sensitive" = "Sensitive Info"; +"Scene.Settings.PrivacyAndSafety.Title" = "Privacy and safety"; "Scene.Settings.SectionHeader.About" = "Honi buruz"; "Scene.Settings.SectionHeader.Account" = "Account"; "Scene.Settings.SectionHeader.General" = "Orokorra"; diff --git a/TwidereSDK/Sources/TwidereLocalization/Resources/gl.lproj/Localizable.strings b/TwidereSDK/Sources/TwidereLocalization/Resources/gl.lproj/Localizable.strings index 101effd7..c581b850 100644 --- a/TwidereSDK/Sources/TwidereLocalization/Resources/gl.lproj/Localizable.strings +++ b/TwidereSDK/Sources/TwidereLocalization/Resources/gl.lproj/Localizable.strings @@ -133,6 +133,8 @@ "Common.Alerts.RateLimitExceeded.Title" = "Taxa de uso superada"; "Common.Alerts.ReportAndBlockUserSuccess.Title" = "Denunciaches e bloqueaches a %@ por spam"; "Common.Alerts.ReportUserSuccess.Title" = "Denunciaches a %@ por spam"; +"Common.Alerts.RequestThrottle.Message" = "Operación moi frecuente. Téntao máis tarde, por favor"; +"Common.Alerts.RequestThrottle.Title" = "Solicitude de aceleración"; "Common.Alerts.SignOutUserConfirm.Message" = "Queres pechar a sesión?"; "Common.Alerts.SignOutUserConfirm.Title" = "Pechar sesión"; "Common.Alerts.TooManyRequests.Title" = "Demasiadas peticións"; @@ -179,6 +181,8 @@ "Common.Controls.Actions.SignOut" = "Pechar sesión"; "Common.Controls.Actions.TakePhoto" = "Tirar foto"; "Common.Controls.Actions.Yes" = "Si"; +"Common.Controls.EmptyState.NoResults" = "Sen resultados"; +"Common.Controls.EmptyState.UnableToAccess" = "Unable to access"; "Common.Controls.Friendship.Actions.Block" = "Bloquear"; "Common.Controls.Friendship.Actions.Blocked" = "Bloqueada"; "Common.Controls.Friendship.Actions.Follow" = "Seguir"; @@ -192,8 +196,8 @@ "Common.Controls.Friendship.Actions.Unfollow" = "Deixar de seguir"; "Common.Controls.Friendship.Actions.Unmute" = "Non acalar"; "Common.Controls.Friendship.BlockUser" = "Bloquear a %@"; -"Common.Controls.Friendship.DoYouWantToReportAnd BlockUser" = "Do you want to report and block %@"; -"Common.Controls.Friendship.DoYouWantToReportUser" = "Do you want to report %@"; +"Common.Controls.Friendship.DoYouWantToReportAndBlockUser" = "Do you want to report and block %@"; +"Common.Controls.Friendship.DoYouWantToReportUser" = "Queres denunciar a %@"; "Common.Controls.Friendship.Follower" = "seguidora"; "Common.Controls.Friendship.Followers" = "seguidores"; "Common.Controls.Friendship.FollowsYou" = "Séguete"; @@ -210,14 +214,20 @@ "Common.Controls.Status.Actions.CopyLink" = "Copiar ligazón"; "Common.Controls.Status.Actions.CopyText" = "Copiar texto"; "Common.Controls.Status.Actions.DeleteTweet" = "Borrar chío"; +"Common.Controls.Status.Actions.Like" = "Favorito"; "Common.Controls.Status.Actions.PinOnProfile" = "Fixar no perfil"; "Common.Controls.Status.Actions.Quote" = "Cita"; +"Common.Controls.Status.Actions.Reply" = "Responder"; +"Common.Controls.Status.Actions.Repost" = "Repost"; "Common.Controls.Status.Actions.Retweet" = "Rechouchío"; "Common.Controls.Status.Actions.SaveMedia" = "Gardar multimedia"; "Common.Controls.Status.Actions.Share" = "Compartir"; "Common.Controls.Status.Actions.ShareContent" = "Compartir contido"; "Common.Controls.Status.Actions.ShareLink" = "Compartir ligazón"; "Common.Controls.Status.Actions.Translate" = "Traducir"; +"Common.Controls.Status.Actions.UndoBoost" = "Undo Boost"; +"Common.Controls.Status.Actions.UndoRepost" = "Undo Repost"; +"Common.Controls.Status.Actions.UndoRetweet" = "Undo Retweet"; "Common.Controls.Status.Actions.UnpinFromProfile" = "Retirar do perfil"; "Common.Controls.Status.Actions.Vote" = "Votar"; "Common.Controls.Status.Media" = "Multimedia"; @@ -274,6 +284,11 @@ "Common.NotificationChannel.ContentMessages.Name" = "Mensaxes"; "Scene.Authentication.Title" = "Autenticación"; "Scene.Bookmark.Title" = "Marcador"; +"Scene.Column.Actions.CloseColumn" = "Close column"; +"Scene.Column.Actions.MoveLeft" = "Move left"; +"Scene.Column.Actions.MoveRight" = "Move right"; +"Scene.Column.Actions.OpenInNewColumn" = "Open in new column"; +"Scene.Column.Title" = "New Column"; "Scene.Compose.And" = ", "; "Scene.Compose.CwPlaceholder" = "Escribe aquí o teu aviso"; "Scene.Compose.LastEnd" = " e "; @@ -314,6 +329,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"; @@ -322,6 +338,11 @@ "Scene.Federated.Title" = "Federada"; "Scene.Followers.Title" = "Seguidores"; "Scene.Following.Title" = "Seguindo"; +"Scene.History.Clear" = "Limpar"; +"Scene.History.Scope.Toot" = "Toot"; +"Scene.History.Scope.Tweet" = "Chío"; +"Scene.History.Scope.User" = "Usuaria"; +"Scene.History.Title" = "Historial"; "Scene.Likes.Title" = "Favoritos"; "Scene.Listed.Title" = "Listado"; "Scene.Lists.Icons.Create" = "Xerar lista"; @@ -360,6 +381,7 @@ "Scene.ListsUsers.MenuActions.Remove" = "Eliminar"; "Scene.Local.Title" = "Local"; "Scene.ManageAccounts.DeleteAccount" = "Eliminar conta"; +"Scene.ManageAccounts.OpenInNewWindow" = "Open in new window"; "Scene.ManageAccounts.Title" = "Contas"; "Scene.Mentions.Title" = "Mencións"; "Scene.Messages.Action.CopyText" = "Copiar texto da mensaxe"; @@ -398,10 +420,9 @@ Aínda en fase previa."; "Scene.Settings.About.Logo.BackgroundShadow" = "Sobre a sombra do logo de fondo da páxina"; "Scene.Settings.About.Title" = "Sobre"; "Scene.Settings.About.Version" = "Ver %@"; -"Scene.Settings.Account.AccountSettings" = "Account Settings"; -"Scene.Settings.Account.BlockedPeople" = "Blocked People"; -"Scene.Settings.Account.MuteAndBlock" = "Mute and Block"; -"Scene.Settings.Account.MutedPeople" = "Muted People"; +"Scene.Settings.Account.BlockedPeople" = "Persoas bloqueadas"; +"Scene.Settings.Account.MuteAndBlock" = "Silenciar e bloquear"; +"Scene.Settings.Account.MutedPeople" = "Persoas silenciadas"; "Scene.Settings.Account.Title" = "Conta"; "Scene.Settings.Appearance.AmoledOptimizedMode" = "Modo otimizado para AMOLED"; "Scene.Settings.Appearance.AppIcon" = "Icona da app"; @@ -425,6 +446,22 @@ Aínda en fase previa."; "Scene.Settings.Appearance.Translation.Off" = "Apagada"; "Scene.Settings.Appearance.Translation.Service" = "Servizo"; "Scene.Settings.Appearance.Translation.TranslateButton" = "Botón traducir"; +"Scene.Settings.Behaviors.HistorySection.EnableHistoryRecord" = "Activar rexistro do historial"; +"Scene.Settings.Behaviors.HistorySection.History" = "Historial"; +"Scene.Settings.Behaviors.TabBarSection.ShowTabBarLabels" = "Amosar etiquetas na barra de lapelas"; +"Scene.Settings.Behaviors.TabBarSection.TabBar" = "Barra de lapelas"; +"Scene.Settings.Behaviors.TabBarSection.TapTabBarScrollToTop" = "Arrolar arriba na barra de lapelas"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.AutomaticallyRefreshTimeline" = "Recarga automática da cronoloxía"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshInterval" = "Intervalo de recarga"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.120Seconds" = "120 segundos"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.300Seconds" = "300 segundos"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.30Seconds" = "30 segundos"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.60Seconds" = "60 segundos"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTop" = "Restablecer"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTopOption.DoubleTap" = "Toque duplo"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTopOption.SingleTap" = "Un toque"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.TimelineRefreshing" = "Recargar cronoloxía"; +"Scene.Settings.Behaviors.Title" = "Comportamentos"; "Scene.Settings.Display.DateFormat.Absolute" = "Absoluta"; "Scene.Settings.Display.DateFormat.Relative" = "Relativa"; "Scene.Settings.Display.Media.Always" = "Sempre"; @@ -434,6 +471,7 @@ Aínda en fase previa."; "Scene.Settings.Display.Media.MuteByDefault" = "Acalar por defecto"; "Scene.Settings.Display.Media.Off" = "Apagada"; "Scene.Settings.Display.Preview.ThankForUsingTwidereX" = "Grazas por usar @TwidereProject!"; +"Scene.Settings.Display.SectionHeader.Avatar" = "Avatar"; "Scene.Settings.Display.SectionHeader.DateFormat" = "Formato da data"; "Scene.Settings.Display.SectionHeader.Media" = "Multimedia"; "Scene.Settings.Display.SectionHeader.Preview" = "Vista previa"; @@ -473,14 +511,18 @@ Aínda en fase previa."; "Scene.Settings.Misc.Proxy.Username" = "Nome de usuaria"; "Scene.Settings.Misc.Title" = "Miscelânea"; "Scene.Settings.Notification.Accounts" = "Contas"; -"Scene.Settings.Notification.Mastodon.Favorite" = "Favorite"; -"Scene.Settings.Notification.Mastodon.Mention" = "Mention"; -"Scene.Settings.Notification.Mastodon.NewFollow" = "New Follow"; -"Scene.Settings.Notification.Mastodon.Poll" = "poll"; -"Scene.Settings.Notification.Mastodon.Reblog" = "Reblog"; +"Scene.Settings.Notification.Mastodon.Favorite" = "Favorito"; +"Scene.Settings.Notification.Mastodon.Mention" = "Mención"; +"Scene.Settings.Notification.Mastodon.NewFollow" = "Novo seguimento"; +"Scene.Settings.Notification.Mastodon.Poll" = "enquisa"; +"Scene.Settings.Notification.Mastodon.Reblog" = "Promover"; "Scene.Settings.Notification.NotificationSwitch" = "Amosar notificación"; -"Scene.Settings.Notification.PushNotification" = "Push Notification"; +"Scene.Settings.Notification.PushNotification" = "Notificacións emerxentes"; "Scene.Settings.Notification.Title" = "Notificación"; +"Scene.Settings.PrivacyAndSafety.AlwaysShowSensitiveMedia" = "Always show sensitive media"; +"Scene.Settings.PrivacyAndSafety.SectionHeader.MuteAndBlock" = "Silenciar e bloquear"; +"Scene.Settings.PrivacyAndSafety.SectionHeader.Sensitive" = "Sensitive Info"; +"Scene.Settings.PrivacyAndSafety.Title" = "Privacy and safety"; "Scene.Settings.SectionHeader.About" = "Sobre"; "Scene.Settings.SectionHeader.Account" = "Conta"; "Scene.Settings.SectionHeader.General" = "Xeral"; diff --git a/TwidereSDK/Sources/TwidereLocalization/Resources/gl.lproj/Localizable.stringsdict b/TwidereSDK/Sources/TwidereLocalization/Resources/gl.lproj/Localizable.stringsdict index 230796c1..d49ef0cc 100644 --- a/TwidereSDK/Sources/TwidereLocalization/Resources/gl.lproj/Localizable.stringsdict +++ b/TwidereSDK/Sources/TwidereLocalization/Resources/gl.lproj/Localizable.stringsdict @@ -13,9 +13,9 @@ NSStringFormatValueTypeKey ld one - 1 notification + 1 notificación other - %ld notifications + %ld notificacións count.media diff --git a/TwidereSDK/Sources/TwidereLocalization/Resources/ja.lproj/Localizable.strings b/TwidereSDK/Sources/TwidereLocalization/Resources/ja.lproj/Localizable.strings index 3c8699fb..e369bf54 100644 --- a/TwidereSDK/Sources/TwidereLocalization/Resources/ja.lproj/Localizable.strings +++ b/TwidereSDK/Sources/TwidereLocalization/Resources/ja.lproj/Localizable.strings @@ -133,6 +133,8 @@ "Common.Alerts.RateLimitExceeded.Title" = "レート制限を超えました"; "Common.Alerts.ReportAndBlockUserSuccess.Title" = "%@ はスパムとして報告されブロックされました"; "Common.Alerts.ReportUserSuccess.Title" = "%@ はスパムとして報告されました"; +"Common.Alerts.RequestThrottle.Message" = "操作が頻繁すぎます。しばらくしてからもう一度お試しください"; +"Common.Alerts.RequestThrottle.Title" = "スロットルを要求する"; "Common.Alerts.SignOutUserConfirm.Message" = "サインアウトしますか?"; "Common.Alerts.SignOutUserConfirm.Title" = "サインアウト"; "Common.Alerts.TooManyRequests.Title" = "リクエストが多すぎます"; @@ -179,6 +181,8 @@ "Common.Controls.Actions.SignOut" = "サインアウト"; "Common.Controls.Actions.TakePhoto" = "写真を撮影"; "Common.Controls.Actions.Yes" = "はい"; +"Common.Controls.EmptyState.NoResults" = "該当なし"; +"Common.Controls.EmptyState.UnableToAccess" = "Unable to access"; "Common.Controls.Friendship.Actions.Block" = "ブロック"; "Common.Controls.Friendship.Actions.Blocked" = "ブロックしました"; "Common.Controls.Friendship.Actions.Follow" = "フォロー"; @@ -192,8 +196,8 @@ "Common.Controls.Friendship.Actions.Unfollow" = "フォロー解除"; "Common.Controls.Friendship.Actions.Unmute" = "ミュート解除"; "Common.Controls.Friendship.BlockUser" = "%@ をブロック"; -"Common.Controls.Friendship.DoYouWantToReportAnd BlockUser" = "Do you want to report and block %@"; -"Common.Controls.Friendship.DoYouWantToReportUser" = "Do you want to report %@"; +"Common.Controls.Friendship.DoYouWantToReportAndBlockUser" = "Do you want to report and block %@"; +"Common.Controls.Friendship.DoYouWantToReportUser" = "%@ を報告しますか?"; "Common.Controls.Friendship.Follower" = "フォロワー"; "Common.Controls.Friendship.Followers" = "フォロワー"; "Common.Controls.Friendship.FollowsYou" = "あなたをフォローしています"; @@ -210,14 +214,20 @@ "Common.Controls.Status.Actions.CopyLink" = "リンクをコピー"; "Common.Controls.Status.Actions.CopyText" = "テキストをコピー"; "Common.Controls.Status.Actions.DeleteTweet" = "ツイートを削除"; +"Common.Controls.Status.Actions.Like" = "いいね"; "Common.Controls.Status.Actions.PinOnProfile" = "プロフィールに固定表示"; "Common.Controls.Status.Actions.Quote" = "引用"; +"Common.Controls.Status.Actions.Reply" = "返信"; +"Common.Controls.Status.Actions.Repost" = "Repost"; "Common.Controls.Status.Actions.Retweet" = "リツイート"; "Common.Controls.Status.Actions.SaveMedia" = "メディアを保存"; "Common.Controls.Status.Actions.Share" = "共有"; "Common.Controls.Status.Actions.ShareContent" = "コンテンツを共有"; "Common.Controls.Status.Actions.ShareLink" = "リンクを共有"; "Common.Controls.Status.Actions.Translate" = "翻訳"; +"Common.Controls.Status.Actions.UndoBoost" = "Undo Boost"; +"Common.Controls.Status.Actions.UndoRepost" = "Undo Repost"; +"Common.Controls.Status.Actions.UndoRetweet" = "Undo Retweet"; "Common.Controls.Status.Actions.UnpinFromProfile" = "プロフィールへの固定を解除"; "Common.Controls.Status.Actions.Vote" = "投票"; "Common.Controls.Status.Media" = "メディア"; @@ -274,6 +284,11 @@ "Common.NotificationChannel.ContentMessages.Name" = "メッセージ"; "Scene.Authentication.Title" = "認証"; "Scene.Bookmark.Title" = "ブックマーク"; +"Scene.Column.Actions.CloseColumn" = "Close column"; +"Scene.Column.Actions.MoveLeft" = "Move left"; +"Scene.Column.Actions.MoveRight" = "Move right"; +"Scene.Column.Actions.OpenInNewColumn" = "Open in new column"; +"Scene.Column.Title" = "New Column"; "Scene.Compose.And" = ", "; "Scene.Compose.CwPlaceholder" = "ここに警告を書いてください"; "Scene.Compose.LastEnd" = " と "; @@ -314,6 +329,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" = "下書き"; @@ -322,6 +338,11 @@ "Scene.Federated.Title" = "連合"; "Scene.Followers.Title" = "フォロワー"; "Scene.Following.Title" = "フォロー中"; +"Scene.History.Clear" = "削除"; +"Scene.History.Scope.Toot" = "トゥート"; +"Scene.History.Scope.Tweet" = "ツイート"; +"Scene.History.Scope.User" = "ユーザー"; +"Scene.History.Title" = "履歴"; "Scene.Likes.Title" = "いいね"; "Scene.Listed.Title" = "リスト"; "Scene.Lists.Icons.Create" = "リストを作成"; @@ -360,6 +381,7 @@ "Scene.ListsUsers.MenuActions.Remove" = "削除"; "Scene.Local.Title" = "ローカル"; "Scene.ManageAccounts.DeleteAccount" = "アカウントの削除"; +"Scene.ManageAccounts.OpenInNewWindow" = "Open in new window"; "Scene.ManageAccounts.Title" = "アカウント"; "Scene.Mentions.Title" = "メンション"; "Scene.Messages.Action.CopyText" = "メッセージをコピー"; @@ -398,11 +420,10 @@ "Scene.Settings.About.Logo.BackgroundShadow" = "情報ページの背景のロゴの影"; "Scene.Settings.About.Title" = "情報"; "Scene.Settings.About.Version" = "Ver %@"; -"Scene.Settings.Account.AccountSettings" = "Account Settings"; -"Scene.Settings.Account.BlockedPeople" = "Blocked People"; -"Scene.Settings.Account.MuteAndBlock" = "Mute and Block"; -"Scene.Settings.Account.MutedPeople" = "Muted People"; -"Scene.Settings.Account.Title" = "Account"; +"Scene.Settings.Account.BlockedPeople" = "ブロックされた人"; +"Scene.Settings.Account.MuteAndBlock" = "ミュートとブロック"; +"Scene.Settings.Account.MutedPeople" = "ミュート中のユーザー"; +"Scene.Settings.Account.Title" = "アカウント"; "Scene.Settings.Appearance.AmoledOptimizedMode" = "AMOLED 最適化モード"; "Scene.Settings.Appearance.AppIcon" = "アプリアイコン"; "Scene.Settings.Appearance.HighlightColor" = "ハイライト色"; @@ -425,6 +446,22 @@ "Scene.Settings.Appearance.Translation.Off" = "オフ"; "Scene.Settings.Appearance.Translation.Service" = "サービス"; "Scene.Settings.Appearance.Translation.TranslateButton" = "翻訳ボタン"; +"Scene.Settings.Behaviors.HistorySection.EnableHistoryRecord" = "履歴レコードを有効にする"; +"Scene.Settings.Behaviors.HistorySection.History" = "履歴"; +"Scene.Settings.Behaviors.TabBarSection.ShowTabBarLabels" = "タブバーのラベルを表示"; +"Scene.Settings.Behaviors.TabBarSection.TabBar" = "タブバー"; +"Scene.Settings.Behaviors.TabBarSection.TapTabBarScrollToTop" = "タブバーをタップして上にスクロールする"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.AutomaticallyRefreshTimeline" = "タイムラインを自動的に更新"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshInterval" = "更新間隔"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.120Seconds" = "120秒"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.300Seconds" = "300秒"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.30Seconds" = "30秒"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.60Seconds" = "60秒"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTop" = "トップにリセット"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTopOption.DoubleTap" = "ダブルタップ"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTopOption.SingleTap" = "シングルタップ"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.TimelineRefreshing" = "タイムライン更新中"; +"Scene.Settings.Behaviors.Title" = "動作"; "Scene.Settings.Display.DateFormat.Absolute" = "絶対"; "Scene.Settings.Display.DateFormat.Relative" = "相対"; "Scene.Settings.Display.Media.Always" = "常に"; @@ -434,6 +471,7 @@ "Scene.Settings.Display.Media.MuteByDefault" = "デフォルトでミュート"; "Scene.Settings.Display.Media.Off" = "オフ"; "Scene.Settings.Display.Preview.ThankForUsingTwidereX" = "@TwidereProject をご利用いただきありがとうございます!"; +"Scene.Settings.Display.SectionHeader.Avatar" = "アバター"; "Scene.Settings.Display.SectionHeader.DateFormat" = "日付フォーマット"; "Scene.Settings.Display.SectionHeader.Media" = "メディア"; "Scene.Settings.Display.SectionHeader.Preview" = "プレビュー"; @@ -474,16 +512,20 @@ "Scene.Settings.Misc.Proxy.Username" = "ユーザー名"; "Scene.Settings.Misc.Title" = "その他"; "Scene.Settings.Notification.Accounts" = "アカウント"; -"Scene.Settings.Notification.Mastodon.Favorite" = "Favorite"; -"Scene.Settings.Notification.Mastodon.Mention" = "Mention"; -"Scene.Settings.Notification.Mastodon.NewFollow" = "New Follow"; -"Scene.Settings.Notification.Mastodon.Poll" = "poll"; -"Scene.Settings.Notification.Mastodon.Reblog" = "Reblog"; +"Scene.Settings.Notification.Mastodon.Favorite" = "いいね"; +"Scene.Settings.Notification.Mastodon.Mention" = "メンション"; +"Scene.Settings.Notification.Mastodon.NewFollow" = "新しいフォロー"; +"Scene.Settings.Notification.Mastodon.Poll" = "投票"; +"Scene.Settings.Notification.Mastodon.Reblog" = "リブログ"; "Scene.Settings.Notification.NotificationSwitch" = "通知を表示"; -"Scene.Settings.Notification.PushNotification" = "Push Notification"; +"Scene.Settings.Notification.PushNotification" = "プッシュ通知"; "Scene.Settings.Notification.Title" = "通知"; +"Scene.Settings.PrivacyAndSafety.AlwaysShowSensitiveMedia" = "Always show sensitive media"; +"Scene.Settings.PrivacyAndSafety.SectionHeader.MuteAndBlock" = "ミュートとブロック"; +"Scene.Settings.PrivacyAndSafety.SectionHeader.Sensitive" = "Sensitive Info"; +"Scene.Settings.PrivacyAndSafety.Title" = "Privacy and safety"; "Scene.Settings.SectionHeader.About" = "情報"; -"Scene.Settings.SectionHeader.Account" = "Account"; +"Scene.Settings.SectionHeader.Account" = "アカウント"; "Scene.Settings.SectionHeader.General" = "一般"; "Scene.Settings.Storage.All.SubTitle" = "すべての Twidere X のキャッシュを削除します。アカウントの情報は失われません。"; "Scene.Settings.Storage.All.Title" = "すべてのキャッシュを削除"; diff --git a/TwidereSDK/Sources/TwidereLocalization/Resources/ja.lproj/Localizable.stringsdict b/TwidereSDK/Sources/TwidereLocalization/Resources/ja.lproj/Localizable.stringsdict index 26c99e7a..5ad032b0 100644 --- a/TwidereSDK/Sources/TwidereLocalization/Resources/ja.lproj/Localizable.stringsdict +++ b/TwidereSDK/Sources/TwidereLocalization/Resources/ja.lproj/Localizable.stringsdict @@ -13,7 +13,7 @@ NSStringFormatValueTypeKey ld other - %ld notifications + %ld件の通知 count.media @@ -47,7 +47,7 @@ count.metric_formatted.post NSStringLocalizedFormatKey - %@ %#@post_count@ + %@%#@post_count@ post_count NSStringFormatSpecTypeKey @@ -55,7 +55,7 @@ NSStringFormatValueTypeKey ld other - posts + 投稿 count.post @@ -181,7 +181,7 @@ NSStringFormatValueTypeKey ld other - %ld people talking + %ld人がこの話題について話しています date.year.left diff --git a/TwidereSDK/Sources/TwidereLocalization/Resources/ko.lproj/Localizable.strings b/TwidereSDK/Sources/TwidereLocalization/Resources/ko.lproj/Localizable.strings index 3d835bb7..e7b96b99 100644 --- a/TwidereSDK/Sources/TwidereLocalization/Resources/ko.lproj/Localizable.strings +++ b/TwidereSDK/Sources/TwidereLocalization/Resources/ko.lproj/Localizable.strings @@ -133,6 +133,8 @@ "Common.Alerts.RateLimitExceeded.Title" = "한도를 넘어서 제한이 걸렸습니다."; "Common.Alerts.ReportAndBlockUserSuccess.Title" = "%@는 스팸으로 신고받아 차단되었습니다."; "Common.Alerts.ReportUserSuccess.Title" = "%@는 스팸으로 신고됐습니다."; +"Common.Alerts.RequestThrottle.Message" = "Operation too frequent. Please try again later"; +"Common.Alerts.RequestThrottle.Title" = "Request Throttle"; "Common.Alerts.SignOutUserConfirm.Message" = "로그아웃 할까요?"; "Common.Alerts.SignOutUserConfirm.Title" = "로그아웃 됐습니다."; "Common.Alerts.TooManyRequests.Title" = "요청이 몰렸습니다."; @@ -179,6 +181,8 @@ "Common.Controls.Actions.SignOut" = "로그아웃 됐습니다."; "Common.Controls.Actions.TakePhoto" = "사진 찍기"; "Common.Controls.Actions.Yes" = "네"; +"Common.Controls.EmptyState.NoResults" = "결과 없음"; +"Common.Controls.EmptyState.UnableToAccess" = "Unable to access"; "Common.Controls.Friendship.Actions.Block" = "차단하기"; "Common.Controls.Friendship.Actions.Blocked" = "차단됨"; "Common.Controls.Friendship.Actions.Follow" = "팔로우하기"; @@ -192,7 +196,7 @@ "Common.Controls.Friendship.Actions.Unfollow" = "팔로우 끊기"; "Common.Controls.Friendship.Actions.Unmute" = "숨기기 풀기"; "Common.Controls.Friendship.BlockUser" = "%@ 차단하기"; -"Common.Controls.Friendship.DoYouWantToReportAnd BlockUser" = "Do you want to report and block %@"; +"Common.Controls.Friendship.DoYouWantToReportAndBlockUser" = "Do you want to report and block %@"; "Common.Controls.Friendship.DoYouWantToReportUser" = "Do you want to report %@"; "Common.Controls.Friendship.Follower" = "팔로워"; "Common.Controls.Friendship.Followers" = "팔로워"; @@ -210,14 +214,20 @@ "Common.Controls.Status.Actions.CopyLink" = "링크 복사하기"; "Common.Controls.Status.Actions.CopyText" = "글 복사하기"; "Common.Controls.Status.Actions.DeleteTweet" = "트윗 지우기"; +"Common.Controls.Status.Actions.Like" = "좋아요"; "Common.Controls.Status.Actions.PinOnProfile" = "프로필에 고정"; "Common.Controls.Status.Actions.Quote" = "인용"; +"Common.Controls.Status.Actions.Reply" = "답글"; +"Common.Controls.Status.Actions.Repost" = "Repost"; "Common.Controls.Status.Actions.Retweet" = "리트윗"; "Common.Controls.Status.Actions.SaveMedia" = "Save media"; "Common.Controls.Status.Actions.Share" = "공유"; "Common.Controls.Status.Actions.ShareContent" = "Share content"; "Common.Controls.Status.Actions.ShareLink" = "링크 공유하기"; "Common.Controls.Status.Actions.Translate" = "번역"; +"Common.Controls.Status.Actions.UndoBoost" = "Undo Boost"; +"Common.Controls.Status.Actions.UndoRepost" = "Undo Repost"; +"Common.Controls.Status.Actions.UndoRetweet" = "Undo Retweet"; "Common.Controls.Status.Actions.UnpinFromProfile" = "프로필에서 고정 풀기"; "Common.Controls.Status.Actions.Vote" = "투표"; "Common.Controls.Status.Media" = "미디어"; @@ -229,8 +239,8 @@ "Common.Controls.Status.ReplySettings.PeopleUserFollowsOrMentionedCanReply" = "People %@ follows or mentioned can reply."; "Common.Controls.Status.ReplySettings.PeopleUserMentionedCanReply" = "People %@ mentioned can reply."; "Common.Controls.Status.Thread.Show" = "이 타래 보기"; -"Common.Controls.Status.UserBoosted" = "%@이 부스트했습니다."; -"Common.Controls.Status.UserRetweeted" = "%@가 리트윗함"; +"Common.Controls.Status.UserBoosted" = "%@ 님이 부스트했습니다."; +"Common.Controls.Status.UserRetweeted" = "%@ 님이 리트윗함"; "Common.Controls.Status.YouBoosted" = "내가 부스트했습니다."; "Common.Controls.Status.YouRetweeted" = "내가 리트윗했습니다."; "Common.Controls.Timeline.LoadMore" = "더 불러오기"; @@ -254,19 +264,19 @@ "Common.Countable.Tweet.Multiple" = "%@개 트윗"; "Common.Countable.Tweet.Single" = "%@개 트윗"; "Common.Notification.Favourite" = "%@가 내 툿에 좋아요를 눌렀습니다"; -"Common.Notification.Follow" = "%@가 나를 팔로합니다."; -"Common.Notification.FollowRequest" = "%@가 팔로 요청을 보냈습니다"; +"Common.Notification.Follow" = "%@ 님이 나를 팔로우합니다."; +"Common.Notification.FollowRequest" = "%@ 님이 팔로우 요청을 보냈습니다"; "Common.Notification.FollowRequestAction.Approve" = "Approve"; "Common.Notification.FollowRequestAction.Deny" = "Deny"; "Common.Notification.FollowRequestResponse.FollowRequestApproved" = "Follow Request Approved"; "Common.Notification.FollowRequestResponse.FollowRequestDenied" = "Follow Request Denied"; -"Common.Notification.Mentions" = "%@가 언급했습니다."; -"Common.Notification.Messages.Content" = "%@님이 메시지를 보냈습니다"; +"Common.Notification.Mentions" = "%@ 님이 언급했습니다."; +"Common.Notification.Messages.Content" = "%@ 님이 메시지를 보냈습니다"; "Common.Notification.Messages.Title" = "새 쪽지"; "Common.Notification.OwnPoll" = "투표가 끝났습니다"; "Common.Notification.Poll" = "투표한 것의 결과가 나왔습니다"; -"Common.Notification.Reblog" = "%@가 내 툿을 부스트 했습니다"; -"Common.Notification.Status" = "%@가 글을 올렸습니다"; +"Common.Notification.Reblog" = "%@ 님이 내 툿을 부스트 했습니다"; +"Common.Notification.Status" = "%@ 님이 글을 올렸습니다"; "Common.NotificationChannel.BackgroundProgresses.Name" = "백그라운드에서 작동"; "Common.NotificationChannel.ContentInteractions.Description" = "답글과 리트윗 같은 알림"; "Common.NotificationChannel.ContentInteractions.Name" = "알림"; @@ -274,6 +284,11 @@ "Common.NotificationChannel.ContentMessages.Name" = "메시지"; "Scene.Authentication.Title" = "인증"; "Scene.Bookmark.Title" = "즐겨찾기"; +"Scene.Column.Actions.CloseColumn" = "Close column"; +"Scene.Column.Actions.MoveLeft" = "Move left"; +"Scene.Column.Actions.MoveRight" = "Move right"; +"Scene.Column.Actions.OpenInNewColumn" = "Open in new column"; +"Scene.Column.Title" = "New Column"; "Scene.Compose.And" = ", "; "Scene.Compose.CwPlaceholder" = "여기에 주의사항을 적어주세요"; "Scene.Compose.LastEnd" = " 그리고 "; @@ -285,7 +300,7 @@ "Scene.Compose.Media.Remove" = "지우기"; "Scene.Compose.OthersInThisConversation" = "이 대화에 있는 다른 사람"; "Scene.Compose.Placeholder" = "무슨 일이 일어나고 있나요?"; -"Scene.Compose.ReplySettings.EveryoneCanReply" = "Everyone can peply"; +"Scene.Compose.ReplySettings.EveryoneCanReply" = "Everyone can reply"; "Scene.Compose.ReplySettings.OnlyPeopleYouMentionCanReply" = "Only people you mention can reply"; "Scene.Compose.ReplySettings.PeopleYouFollowCanReply" = "People you follow can reply"; "Scene.Compose.ReplyTo" = "…에 답글"; @@ -314,6 +329,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" = "임시 보관함"; @@ -322,6 +338,11 @@ "Scene.Federated.Title" = "연합"; "Scene.Followers.Title" = "팔로워"; "Scene.Following.Title" = "팔로우 중"; +"Scene.History.Clear" = "Clear"; +"Scene.History.Scope.Toot" = "툿"; +"Scene.History.Scope.Tweet" = "트윗"; +"Scene.History.Scope.User" = "User"; +"Scene.History.Title" = "기록"; "Scene.Likes.Title" = "좋아요"; "Scene.Listed.Title" = "담긴 리스트"; "Scene.Lists.Icons.Create" = "리스트 만들기"; @@ -360,6 +381,7 @@ "Scene.ListsUsers.MenuActions.Remove" = "지우기"; "Scene.Local.Title" = "로컬"; "Scene.ManageAccounts.DeleteAccount" = "계정 지우기"; +"Scene.ManageAccounts.OpenInNewWindow" = "Open in new window"; "Scene.ManageAccounts.Title" = "계정"; "Scene.Mentions.Title" = "답글"; "Scene.Messages.Action.CopyText" = "메시지 글자 복사"; @@ -398,7 +420,6 @@ "Scene.Settings.About.Logo.BackgroundShadow" = "페이지 바탕 로고의 그림자에 대하여"; "Scene.Settings.About.Title" = "정보"; "Scene.Settings.About.Version" = "%@ 버전"; -"Scene.Settings.Account.AccountSettings" = "Account Settings"; "Scene.Settings.Account.BlockedPeople" = "Blocked People"; "Scene.Settings.Account.MuteAndBlock" = "Mute and Block"; "Scene.Settings.Account.MutedPeople" = "Muted People"; @@ -425,6 +446,22 @@ "Scene.Settings.Appearance.Translation.Off" = "끄기"; "Scene.Settings.Appearance.Translation.Service" = "Service"; "Scene.Settings.Appearance.Translation.TranslateButton" = "Translate button"; +"Scene.Settings.Behaviors.HistorySection.EnableHistoryRecord" = "Enable History Record"; +"Scene.Settings.Behaviors.HistorySection.History" = "기록"; +"Scene.Settings.Behaviors.TabBarSection.ShowTabBarLabels" = "Show tab bar labels"; +"Scene.Settings.Behaviors.TabBarSection.TabBar" = "Tab Bar"; +"Scene.Settings.Behaviors.TabBarSection.TapTabBarScrollToTop" = "Tap tab bar scroll to top"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.AutomaticallyRefreshTimeline" = "Automatically refresh timeline"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshInterval" = "Refresh interval"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.120Seconds" = "120 seconds"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.300Seconds" = "300 seconds"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.30Seconds" = "30 seconds"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.60Seconds" = "60 seconds"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTop" = "Reset to top"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTopOption.DoubleTap" = "Double Tap"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTopOption.SingleTap" = "Single Tap"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.TimelineRefreshing" = "Timeline Refreshing"; +"Scene.Settings.Behaviors.Title" = "Behaviors"; "Scene.Settings.Display.DateFormat.Absolute" = "절대 시간"; "Scene.Settings.Display.DateFormat.Relative" = "상대적 시간 (~분 전)"; "Scene.Settings.Display.Media.Always" = "언제나"; @@ -434,6 +471,7 @@ "Scene.Settings.Display.Media.MuteByDefault" = "기본으로 숨기기"; "Scene.Settings.Display.Media.Off" = "끄기"; "Scene.Settings.Display.Preview.ThankForUsingTwidereX" = "@TwidereProject를 써주셔서 고맙습니다!"; +"Scene.Settings.Display.SectionHeader.Avatar" = "Avatar"; "Scene.Settings.Display.SectionHeader.DateFormat" = "날짜 형식"; "Scene.Settings.Display.SectionHeader.Media" = "미디어"; "Scene.Settings.Display.SectionHeader.Preview" = "미리보기"; @@ -481,6 +519,10 @@ "Scene.Settings.Notification.NotificationSwitch" = "알림 보이기"; "Scene.Settings.Notification.PushNotification" = "Push Notification"; "Scene.Settings.Notification.Title" = "알림"; +"Scene.Settings.PrivacyAndSafety.AlwaysShowSensitiveMedia" = "Always show sensitive media"; +"Scene.Settings.PrivacyAndSafety.SectionHeader.MuteAndBlock" = "Mute and Block"; +"Scene.Settings.PrivacyAndSafety.SectionHeader.Sensitive" = "Sensitive Info"; +"Scene.Settings.PrivacyAndSafety.Title" = "Privacy and safety"; "Scene.Settings.SectionHeader.About" = "정보"; "Scene.Settings.SectionHeader.Account" = "계정"; "Scene.Settings.SectionHeader.General" = "일반"; diff --git a/TwidereSDK/Sources/TwidereLocalization/Resources/pt-BR.lproj/Localizable.strings b/TwidereSDK/Sources/TwidereLocalization/Resources/pt-BR.lproj/Localizable.strings index caba44f9..a00239ad 100644 --- a/TwidereSDK/Sources/TwidereLocalization/Resources/pt-BR.lproj/Localizable.strings +++ b/TwidereSDK/Sources/TwidereLocalization/Resources/pt-BR.lproj/Localizable.strings @@ -133,6 +133,8 @@ "Common.Alerts.RateLimitExceeded.Title" = "Limite de Acesso Excedido"; "Common.Alerts.ReportAndBlockUserSuccess.Title" = "%@ foi reportado por spam e bloqueado"; "Common.Alerts.ReportUserSuccess.Title" = "%@ foi reportado por spam"; +"Common.Alerts.RequestThrottle.Message" = "Operação muito frequente. Por favor, tente novamente mais tarde"; +"Common.Alerts.RequestThrottle.Title" = "Solicitar Acelerador"; "Common.Alerts.SignOutUserConfirm.Message" = "Você quer encerrar esta sessão?"; "Common.Alerts.SignOutUserConfirm.Title" = "Encerrar sessão"; "Common.Alerts.TooManyRequests.Title" = "Muitos Pedidos"; @@ -179,6 +181,8 @@ "Common.Controls.Actions.SignOut" = "Encerrar sessão"; "Common.Controls.Actions.TakePhoto" = "Tirar foto"; "Common.Controls.Actions.Yes" = "Sim"; +"Common.Controls.EmptyState.NoResults" = "Nenhum resultado"; +"Common.Controls.EmptyState.UnableToAccess" = "Unable to access"; "Common.Controls.Friendship.Actions.Block" = "Bloquear"; "Common.Controls.Friendship.Actions.Blocked" = "Bloqueado"; "Common.Controls.Friendship.Actions.Follow" = "Seguir"; @@ -192,8 +196,8 @@ "Common.Controls.Friendship.Actions.Unfollow" = "Deixar de seguir"; "Common.Controls.Friendship.Actions.Unmute" = "Dessilenciar"; "Common.Controls.Friendship.BlockUser" = "Bloquear %@"; -"Common.Controls.Friendship.DoYouWantToReportAnd BlockUser" = "Do you want to report and block %@"; -"Common.Controls.Friendship.DoYouWantToReportUser" = "Do you want to report %@"; +"Common.Controls.Friendship.DoYouWantToReportAndBlockUser" = "Do you want to report and block %@"; +"Common.Controls.Friendship.DoYouWantToReportUser" = "Você quer reportar %@"; "Common.Controls.Friendship.Follower" = "seguidor"; "Common.Controls.Friendship.Followers" = "seguidores"; "Common.Controls.Friendship.FollowsYou" = "Segue você"; @@ -210,14 +214,20 @@ "Common.Controls.Status.Actions.CopyLink" = "Copiar link"; "Common.Controls.Status.Actions.CopyText" = "Copiar texto"; "Common.Controls.Status.Actions.DeleteTweet" = "Excluir tweet"; +"Common.Controls.Status.Actions.Like" = "Curtir"; "Common.Controls.Status.Actions.PinOnProfile" = "Fixar no Perfil"; "Common.Controls.Status.Actions.Quote" = "Citação"; +"Common.Controls.Status.Actions.Reply" = "Responder"; +"Common.Controls.Status.Actions.Repost" = "Repost"; "Common.Controls.Status.Actions.Retweet" = "Retweet"; "Common.Controls.Status.Actions.SaveMedia" = "Salvar mídia"; "Common.Controls.Status.Actions.Share" = "Compartilhar"; "Common.Controls.Status.Actions.ShareContent" = "Compartilhar conteúdo"; "Common.Controls.Status.Actions.ShareLink" = "Compartilhar link"; "Common.Controls.Status.Actions.Translate" = "Traduzir"; +"Common.Controls.Status.Actions.UndoBoost" = "Undo Boost"; +"Common.Controls.Status.Actions.UndoRepost" = "Undo Repost"; +"Common.Controls.Status.Actions.UndoRetweet" = "Undo Retweet"; "Common.Controls.Status.Actions.UnpinFromProfile" = "Desafixar do Perfil"; "Common.Controls.Status.Actions.Vote" = "Votar"; "Common.Controls.Status.Media" = "Mídia"; @@ -274,6 +284,11 @@ "Common.NotificationChannel.ContentMessages.Name" = "Mensagens"; "Scene.Authentication.Title" = "Autenticação"; "Scene.Bookmark.Title" = "Favorito"; +"Scene.Column.Actions.CloseColumn" = "Close column"; +"Scene.Column.Actions.MoveLeft" = "Move left"; +"Scene.Column.Actions.MoveRight" = "Move right"; +"Scene.Column.Actions.OpenInNewColumn" = "Open in new column"; +"Scene.Column.Title" = "New Column"; "Scene.Compose.And" = ", "; "Scene.Compose.CwPlaceholder" = "Escreva seu aviso aqui"; "Scene.Compose.LastEnd" = " e "; @@ -314,6 +329,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"; @@ -322,6 +338,11 @@ "Scene.Federated.Title" = "Federado"; "Scene.Followers.Title" = "Seguidores"; "Scene.Following.Title" = "Seguindo"; +"Scene.History.Clear" = "Limpar"; +"Scene.History.Scope.Toot" = "Toot"; +"Scene.History.Scope.Tweet" = "Tweet"; +"Scene.History.Scope.User" = "Usuário"; +"Scene.History.Title" = "Histórico"; "Scene.Likes.Title" = "Curtidas"; "Scene.Listed.Title" = "Listado"; "Scene.Lists.Icons.Create" = "Criar lista"; @@ -360,6 +381,7 @@ "Scene.ListsUsers.MenuActions.Remove" = "Remover"; "Scene.Local.Title" = "Local"; "Scene.ManageAccounts.DeleteAccount" = "Excluir conta"; +"Scene.ManageAccounts.OpenInNewWindow" = "Open in new window"; "Scene.ManageAccounts.Title" = "Contas"; "Scene.Mentions.Title" = "Menções"; "Scene.Messages.Action.CopyText" = "Copiar texto da mensagem"; @@ -398,10 +420,9 @@ Ainda na fase inicial."; "Scene.Settings.About.Logo.BackgroundShadow" = "Sobre a sombra do logo da página de fundo"; "Scene.Settings.About.Title" = "Sobre"; "Scene.Settings.About.Version" = "Ver %@"; -"Scene.Settings.Account.AccountSettings" = "Account Settings"; -"Scene.Settings.Account.BlockedPeople" = "Blocked People"; -"Scene.Settings.Account.MuteAndBlock" = "Mute and Block"; -"Scene.Settings.Account.MutedPeople" = "Muted People"; +"Scene.Settings.Account.BlockedPeople" = "Pessoas Bloqueadas"; +"Scene.Settings.Account.MuteAndBlock" = "Silenciar e Bloquear"; +"Scene.Settings.Account.MutedPeople" = "Pessoas Silenciadas"; "Scene.Settings.Account.Title" = "Conta"; "Scene.Settings.Appearance.AmoledOptimizedMode" = "Modo otimizado para AMOLED"; "Scene.Settings.Appearance.AppIcon" = "Ícone do Aplicativo"; @@ -425,6 +446,22 @@ Ainda na fase inicial."; "Scene.Settings.Appearance.Translation.Off" = "Desligado"; "Scene.Settings.Appearance.Translation.Service" = "Serviço"; "Scene.Settings.Appearance.Translation.TranslateButton" = "Botão de tradução"; +"Scene.Settings.Behaviors.HistorySection.EnableHistoryRecord" = "Ativar Registro de Histórico"; +"Scene.Settings.Behaviors.HistorySection.History" = "Histórico"; +"Scene.Settings.Behaviors.TabBarSection.ShowTabBarLabels" = "Mostrar rótulos da barra de abas"; +"Scene.Settings.Behaviors.TabBarSection.TabBar" = "Barra de Abas"; +"Scene.Settings.Behaviors.TabBarSection.TapTabBarScrollToTop" = "Toque na barra de abas para rolar até o topo"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.AutomaticallyRefreshTimeline" = "Atualizar linha do tempo automaticamente"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshInterval" = "Intervalo de atualização"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.120Seconds" = "120 segundos"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.300Seconds" = "300 segundos"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.30Seconds" = "30 segundos"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.60Seconds" = "60 segundos"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTop" = "Redefinir para o topo"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTopOption.DoubleTap" = "Toque Duplo"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTopOption.SingleTap" = "Toque Único"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.TimelineRefreshing" = "Atualização da Linha do Tempo"; +"Scene.Settings.Behaviors.Title" = "Comportamentos"; "Scene.Settings.Display.DateFormat.Absolute" = "Absoluto"; "Scene.Settings.Display.DateFormat.Relative" = "Relativo"; "Scene.Settings.Display.Media.Always" = "Sempre"; @@ -434,6 +471,7 @@ Ainda na fase inicial."; "Scene.Settings.Display.Media.MuteByDefault" = "Silenciar por padrão"; "Scene.Settings.Display.Media.Off" = "Desligado"; "Scene.Settings.Display.Preview.ThankForUsingTwidereX" = "Obrigado por usar o @TwidereProject!"; +"Scene.Settings.Display.SectionHeader.Avatar" = "Avatar"; "Scene.Settings.Display.SectionHeader.DateFormat" = "Formato da Data"; "Scene.Settings.Display.SectionHeader.Media" = "Mídia"; "Scene.Settings.Display.SectionHeader.Preview" = "Pré-visualização"; @@ -473,14 +511,18 @@ Ainda na fase inicial."; "Scene.Settings.Misc.Proxy.Username" = "Nome de usuário"; "Scene.Settings.Misc.Title" = "Miscelânea"; "Scene.Settings.Notification.Accounts" = "Contas"; -"Scene.Settings.Notification.Mastodon.Favorite" = "Favorite"; -"Scene.Settings.Notification.Mastodon.Mention" = "Mention"; -"Scene.Settings.Notification.Mastodon.NewFollow" = "New Follow"; -"Scene.Settings.Notification.Mastodon.Poll" = "poll"; -"Scene.Settings.Notification.Mastodon.Reblog" = "Reblog"; +"Scene.Settings.Notification.Mastodon.Favorite" = "Favorito"; +"Scene.Settings.Notification.Mastodon.Mention" = "Menção"; +"Scene.Settings.Notification.Mastodon.NewFollow" = "Novo Seguidor"; +"Scene.Settings.Notification.Mastodon.Poll" = "enquete"; +"Scene.Settings.Notification.Mastodon.Reblog" = "Reblogar"; "Scene.Settings.Notification.NotificationSwitch" = "Mostrar Notificação"; -"Scene.Settings.Notification.PushNotification" = "Push Notification"; +"Scene.Settings.Notification.PushNotification" = "Notificação Push"; "Scene.Settings.Notification.Title" = "Notificação"; +"Scene.Settings.PrivacyAndSafety.AlwaysShowSensitiveMedia" = "Always show sensitive media"; +"Scene.Settings.PrivacyAndSafety.SectionHeader.MuteAndBlock" = "Silenciar e Bloquear"; +"Scene.Settings.PrivacyAndSafety.SectionHeader.Sensitive" = "Sensitive Info"; +"Scene.Settings.PrivacyAndSafety.Title" = "Privacy and safety"; "Scene.Settings.SectionHeader.About" = "Sobre"; "Scene.Settings.SectionHeader.Account" = "Conta"; "Scene.Settings.SectionHeader.General" = "Geral"; diff --git a/TwidereSDK/Sources/TwidereLocalization/Resources/pt-BR.lproj/Localizable.stringsdict b/TwidereSDK/Sources/TwidereLocalization/Resources/pt-BR.lproj/Localizable.stringsdict index 149368ad..96ae236b 100644 --- a/TwidereSDK/Sources/TwidereLocalization/Resources/pt-BR.lproj/Localizable.stringsdict +++ b/TwidereSDK/Sources/TwidereLocalization/Resources/pt-BR.lproj/Localizable.stringsdict @@ -13,9 +13,9 @@ NSStringFormatValueTypeKey ld one - 1 notification + 1 notificação other - %ld notifications + %ld notificações count.media diff --git a/TwidereSDK/Sources/TwidereLocalization/Resources/tr.lproj/Localizable.strings b/TwidereSDK/Sources/TwidereLocalization/Resources/tr.lproj/Localizable.strings index 44f70bba..c95340cc 100644 --- a/TwidereSDK/Sources/TwidereLocalization/Resources/tr.lproj/Localizable.strings +++ b/TwidereSDK/Sources/TwidereLocalization/Resources/tr.lproj/Localizable.strings @@ -133,6 +133,8 @@ "Common.Alerts.RateLimitExceeded.Title" = "Kullanım limiti aşıldı"; "Common.Alerts.ReportAndBlockUserSuccess.Title" = "%@ spam için şikayet edildi ve engellendi"; "Common.Alerts.ReportUserSuccess.Title" = "%@ spam için şikayet edildi"; +"Common.Alerts.RequestThrottle.Message" = "İşlem çok sık oldu. Lütfen daha sonra tekrar deneyin"; +"Common.Alerts.RequestThrottle.Title" = "Talep kısıtlaması"; "Common.Alerts.SignOutUserConfirm.Message" = "Çıkış yapmak istiyor musunuz?"; "Common.Alerts.SignOutUserConfirm.Title" = "Çıkış Yap"; "Common.Alerts.TooManyRequests.Title" = "Çok Fazla İstek"; @@ -179,6 +181,8 @@ "Common.Controls.Actions.SignOut" = "Çıkış Yap"; "Common.Controls.Actions.TakePhoto" = "Fotoğraf çek"; "Common.Controls.Actions.Yes" = "Evet"; +"Common.Controls.EmptyState.NoResults" = "Sonuç bulunamadı"; +"Common.Controls.EmptyState.UnableToAccess" = "Unable to access"; "Common.Controls.Friendship.Actions.Block" = "Engelle"; "Common.Controls.Friendship.Actions.Blocked" = "Engellenmiş"; "Common.Controls.Friendship.Actions.Follow" = "Takip et"; @@ -192,8 +196,8 @@ "Common.Controls.Friendship.Actions.Unfollow" = "Takibi bırak"; "Common.Controls.Friendship.Actions.Unmute" = "Sessizden çıkar"; "Common.Controls.Friendship.BlockUser" = "%@ Engelle"; -"Common.Controls.Friendship.DoYouWantToReportAnd BlockUser" = "Do you want to report and block %@"; -"Common.Controls.Friendship.DoYouWantToReportUser" = "Do you want to report %@"; +"Common.Controls.Friendship.DoYouWantToReportAndBlockUser" = "%@ kullanıcısını bildirmek ve engellemek istiyor musunuz"; +"Common.Controls.Friendship.DoYouWantToReportUser" = "%@ kullanıcısını bildirmek istiyor musunuz"; "Common.Controls.Friendship.Follower" = "takipçi"; "Common.Controls.Friendship.Followers" = "takipçiler"; "Common.Controls.Friendship.FollowsYou" = "Sizi takip ediyor"; @@ -210,14 +214,20 @@ "Common.Controls.Status.Actions.CopyLink" = "Linki kopyala"; "Common.Controls.Status.Actions.CopyText" = "Metni kopyala"; "Common.Controls.Status.Actions.DeleteTweet" = "Tweeti sil"; +"Common.Controls.Status.Actions.Like" = "Beğen"; "Common.Controls.Status.Actions.PinOnProfile" = "Profilde Sabitle"; "Common.Controls.Status.Actions.Quote" = "Alıntı"; +"Common.Controls.Status.Actions.Reply" = "Yanıt"; +"Common.Controls.Status.Actions.Repost" = "Repost"; "Common.Controls.Status.Actions.Retweet" = "Retweetle"; "Common.Controls.Status.Actions.SaveMedia" = "Medyayı Kaydet"; "Common.Controls.Status.Actions.Share" = "Paylaş"; "Common.Controls.Status.Actions.ShareContent" = "İçeriği paylaş"; "Common.Controls.Status.Actions.ShareLink" = "Linki paylaş"; "Common.Controls.Status.Actions.Translate" = "Çevir"; +"Common.Controls.Status.Actions.UndoBoost" = "Undo Boost"; +"Common.Controls.Status.Actions.UndoRepost" = "Undo Repost"; +"Common.Controls.Status.Actions.UndoRetweet" = "Undo Retweet"; "Common.Controls.Status.Actions.UnpinFromProfile" = "Profilden sabitlemeyi kaldır"; "Common.Controls.Status.Actions.Vote" = "Oy ver"; "Common.Controls.Status.Media" = "Medya"; @@ -226,8 +236,8 @@ "Common.Controls.Status.Poll.TotalPerson" = "%@ kişi"; "Common.Controls.Status.Poll.TotalVote" = "%@ oy"; "Common.Controls.Status.Poll.TotalVotes" = "%@ oylar"; -"Common.Controls.Status.ReplySettings.PeopleUserFollowsOrMentionedCanReply" = "People %@ follows or mentioned can reply."; -"Common.Controls.Status.ReplySettings.PeopleUserMentionedCanReply" = "People %@ mentioned can reply."; +"Common.Controls.Status.ReplySettings.PeopleUserFollowsOrMentionedCanReply" = "%@ takip eden veya adı geçen kişiler cevap verebilir."; +"Common.Controls.Status.ReplySettings.PeopleUserMentionedCanReply" = "%@ bahsi geçen kişiler yanıt verebilir."; "Common.Controls.Status.Thread.Show" = "Bu konuyu göster"; "Common.Controls.Status.UserBoosted" = "%@ artırıldı"; "Common.Controls.Status.UserRetweeted" = "%@ retweetledi"; @@ -274,6 +284,11 @@ "Common.NotificationChannel.ContentMessages.Name" = "Mesajlar"; "Scene.Authentication.Title" = "Yetkilendirme"; "Scene.Bookmark.Title" = "Yer işareti"; +"Scene.Column.Actions.CloseColumn" = "Sütunu kapat"; +"Scene.Column.Actions.MoveLeft" = "Sola taşı"; +"Scene.Column.Actions.MoveRight" = "Sağa taşı"; +"Scene.Column.Actions.OpenInNewColumn" = "Yeni sütunda"; +"Scene.Column.Title" = "Yeni sütun"; "Scene.Compose.And" = ", "; "Scene.Compose.CwPlaceholder" = "Uyarınızı buraya yazın"; "Scene.Compose.LastEnd" = " ve "; @@ -314,6 +329,7 @@ "Scene.Compose.Vote.PlaceholderIndex" = "Seçim %d"; "Scene.ComposeHashtagSearch.SearchPlaceholder" = "Hashtag ara"; "Scene.ComposeUserSearch.SearchPlaceholder" = "Kullanıcıları ara"; +"Scene.Detail.Title" = "Ayrıntılar"; "Scene.Drafts.Actions.DeleteDraft" = "Taslağı sil"; "Scene.Drafts.Actions.EditDraft" = "Taslağı düzenle"; "Scene.Drafts.Title" = "Taslaklar"; @@ -322,6 +338,11 @@ "Scene.Federated.Title" = "Federe olmuş"; "Scene.Followers.Title" = "Takipçiler"; "Scene.Following.Title" = "Takip ediliyor"; +"Scene.History.Clear" = "Temizle"; +"Scene.History.Scope.Toot" = "Toot"; +"Scene.History.Scope.Tweet" = "Tweet'le"; +"Scene.History.Scope.User" = "Kullanıcı"; +"Scene.History.Title" = "Geçmiş"; "Scene.Likes.Title" = "Beğeniler"; "Scene.Listed.Title" = "Sıralanan"; "Scene.Lists.Icons.Create" = "Liste oluştur"; @@ -360,6 +381,7 @@ "Scene.ListsUsers.MenuActions.Remove" = "Kaldır"; "Scene.Local.Title" = "Yerel"; "Scene.ManageAccounts.DeleteAccount" = "Hesabı sil"; +"Scene.ManageAccounts.OpenInNewWindow" = "Yeni pencerede aç"; "Scene.ManageAccounts.Title" = "Hesaplar"; "Scene.Mentions.Title" = "Etiketler"; "Scene.Messages.Action.CopyText" = "Mesaj metnini kopyala"; @@ -386,7 +408,7 @@ "Scene.Search.ShowMore" = "Daha fazla göster"; "Scene.Search.Tabs.Hashtag" = "Etiket"; "Scene.Search.Tabs.Media" = "Medya"; -"Scene.Search.Tabs.People" = "İnsanlar"; +"Scene.Search.Tabs.People" = "Kişiler"; "Scene.Search.Tabs.Toots" = "Tootlar"; "Scene.Search.Tabs.Tweets" = "Tweetler"; "Scene.Search.Tabs.Users" = "Kullanıcılar"; @@ -398,10 +420,9 @@ Hala erken aşamada."; "Scene.Settings.About.Logo.BackgroundShadow" = "Sayfa arka plan logosu gölgesi hakkında"; "Scene.Settings.About.Title" = "Hakkında"; "Scene.Settings.About.Version" = "Sür %@"; -"Scene.Settings.Account.AccountSettings" = "Account Settings"; -"Scene.Settings.Account.BlockedPeople" = "Blocked People"; -"Scene.Settings.Account.MuteAndBlock" = "Mute and Block"; -"Scene.Settings.Account.MutedPeople" = "Muted People"; +"Scene.Settings.Account.BlockedPeople" = "Engellenmiş kişiler"; +"Scene.Settings.Account.MuteAndBlock" = "Sustur ve Engelle"; +"Scene.Settings.Account.MutedPeople" = "Susturulmuş kişiler"; "Scene.Settings.Account.Title" = "Hesabım"; "Scene.Settings.Appearance.AmoledOptimizedMode" = "AMOLED optimize edilmiş mod"; "Scene.Settings.Appearance.AppIcon" = "Uygulama Simgesi"; @@ -425,6 +446,22 @@ Hala erken aşamada."; "Scene.Settings.Appearance.Translation.Off" = "Kapalı"; "Scene.Settings.Appearance.Translation.Service" = "Hizmet"; "Scene.Settings.Appearance.Translation.TranslateButton" = "Çevir butonu"; +"Scene.Settings.Behaviors.HistorySection.EnableHistoryRecord" = "Geçmiş Kaydını Etkinleştir"; +"Scene.Settings.Behaviors.HistorySection.History" = "Geçmiş"; +"Scene.Settings.Behaviors.TabBarSection.ShowTabBarLabels" = "Sekme çubuğu etiketlerini göster"; +"Scene.Settings.Behaviors.TabBarSection.TabBar" = "Sekme Çubuğu"; +"Scene.Settings.Behaviors.TabBarSection.TapTabBarScrollToTop" = "Yukarı kaydırmak için sekme çubuğuna dokun"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.AutomaticallyRefreshTimeline" = "Otomatik olarak zaman çizelgesini yenile"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshInterval" = "Yenileme aralığı"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.120Seconds" = "120 saniye"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.300Seconds" = "300 saniye"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.30Seconds" = "30 saniye"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.60Seconds" = "60 saniye"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTop" = "Başa sıfırla"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTopOption.DoubleTap" = "Çift dokunuş"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTopOption.SingleTap" = "Tek dokunuş"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.TimelineRefreshing" = "Zaman Çizelgesi Yenileniyor"; +"Scene.Settings.Behaviors.Title" = "Davranışlar"; "Scene.Settings.Display.DateFormat.Absolute" = "Kesin"; "Scene.Settings.Display.DateFormat.Relative" = "Göreceli"; "Scene.Settings.Display.Media.Always" = "Daima"; @@ -434,6 +471,7 @@ Hala erken aşamada."; "Scene.Settings.Display.Media.MuteByDefault" = "Varsayılan olarak sustur"; "Scene.Settings.Display.Media.Off" = "Kapalı"; "Scene.Settings.Display.Preview.ThankForUsingTwidereX" = "@TwidereProject kullandığınız için teşekkürler!"; +"Scene.Settings.Display.SectionHeader.Avatar" = "Profil Resmi"; "Scene.Settings.Display.SectionHeader.DateFormat" = "Tarih biçimi"; "Scene.Settings.Display.SectionHeader.Media" = "Medya"; "Scene.Settings.Display.SectionHeader.Preview" = "Önizleme"; @@ -473,14 +511,18 @@ Hala erken aşamada."; "Scene.Settings.Misc.Proxy.Username" = "Kullanıcı adı"; "Scene.Settings.Misc.Title" = "Diğer"; "Scene.Settings.Notification.Accounts" = "Hesaplar"; -"Scene.Settings.Notification.Mastodon.Favorite" = "Favorite"; -"Scene.Settings.Notification.Mastodon.Mention" = "Mention"; -"Scene.Settings.Notification.Mastodon.NewFollow" = "New Follow"; -"Scene.Settings.Notification.Mastodon.Poll" = "poll"; -"Scene.Settings.Notification.Mastodon.Reblog" = "Reblog"; +"Scene.Settings.Notification.Mastodon.Favorite" = "Favori"; +"Scene.Settings.Notification.Mastodon.Mention" = "Bahset"; +"Scene.Settings.Notification.Mastodon.NewFollow" = "Yeni takip"; +"Scene.Settings.Notification.Mastodon.Poll" = "anket"; +"Scene.Settings.Notification.Mastodon.Reblog" = "Yeniden yayınla"; "Scene.Settings.Notification.NotificationSwitch" = "Bildirim göster"; -"Scene.Settings.Notification.PushNotification" = "Push Notification"; +"Scene.Settings.Notification.PushNotification" = "Anlık bildirim"; "Scene.Settings.Notification.Title" = "Bildirim"; +"Scene.Settings.PrivacyAndSafety.AlwaysShowSensitiveMedia" = "Hassas medyayı her zaman göster"; +"Scene.Settings.PrivacyAndSafety.SectionHeader.MuteAndBlock" = "Sustur ve Engelle"; +"Scene.Settings.PrivacyAndSafety.SectionHeader.Sensitive" = "Hassas Bilgi"; +"Scene.Settings.PrivacyAndSafety.Title" = "Gizlilik ve güvenlik"; "Scene.Settings.SectionHeader.About" = "Hakkında"; "Scene.Settings.SectionHeader.Account" = "Hesabım"; "Scene.Settings.SectionHeader.General" = "Genel"; diff --git a/TwidereSDK/Sources/TwidereLocalization/Resources/tr.lproj/Localizable.stringsdict b/TwidereSDK/Sources/TwidereLocalization/Resources/tr.lproj/Localizable.stringsdict index d90abaa6..6bcab9ac 100644 --- a/TwidereSDK/Sources/TwidereLocalization/Resources/tr.lproj/Localizable.stringsdict +++ b/TwidereSDK/Sources/TwidereLocalization/Resources/tr.lproj/Localizable.stringsdict @@ -13,9 +13,9 @@ NSStringFormatValueTypeKey ld one - 1 notification + 1 bildirim other - %ld notifications + %ld bildirim count.media diff --git a/TwidereSDK/Sources/TwidereLocalization/Resources/zh-Hans.lproj/Localizable.strings b/TwidereSDK/Sources/TwidereLocalization/Resources/zh-Hans.lproj/Localizable.strings index e6bdafcb..d1de15bc 100644 --- a/TwidereSDK/Sources/TwidereLocalization/Resources/zh-Hans.lproj/Localizable.strings +++ b/TwidereSDK/Sources/TwidereLocalization/Resources/zh-Hans.lproj/Localizable.strings @@ -133,6 +133,8 @@ "Common.Alerts.RateLimitExceeded.Title" = "超出用量限制"; "Common.Alerts.ReportAndBlockUserSuccess.Title" = "%@ 已被举报为垃圾信息用户并被屏蔽"; "Common.Alerts.ReportUserSuccess.Title" = "%@ 已被举报为垃圾信息用户"; +"Common.Alerts.RequestThrottle.Message" = "操作过于频繁,请稍后再试"; +"Common.Alerts.RequestThrottle.Title" = "请求受限"; "Common.Alerts.SignOutUserConfirm.Message" = "确定要登出吗?"; "Common.Alerts.SignOutUserConfirm.Title" = "登出"; "Common.Alerts.TooManyRequests.Title" = "请求次数过多"; @@ -179,6 +181,8 @@ "Common.Controls.Actions.SignOut" = "登出"; "Common.Controls.Actions.TakePhoto" = "拍摄照片"; "Common.Controls.Actions.Yes" = "确定"; +"Common.Controls.EmptyState.NoResults" = "无结果"; +"Common.Controls.EmptyState.UnableToAccess" = "无法访问"; "Common.Controls.Friendship.Actions.Block" = "屏蔽"; "Common.Controls.Friendship.Actions.Blocked" = "已屏蔽"; "Common.Controls.Friendship.Actions.Follow" = "关注"; @@ -192,7 +196,7 @@ "Common.Controls.Friendship.Actions.Unfollow" = "取消关注"; "Common.Controls.Friendship.Actions.Unmute" = "解除静音"; "Common.Controls.Friendship.BlockUser" = "屏蔽 %@"; -"Common.Controls.Friendship.DoYouWantToReportAnd BlockUser" = "是否要举报并屏蔽 %@"; +"Common.Controls.Friendship.DoYouWantToReportAndBlockUser" = "是否要举报并屏蔽 %@"; "Common.Controls.Friendship.DoYouWantToReportUser" = "是否要举报 %@"; "Common.Controls.Friendship.Follower" = "关注者"; "Common.Controls.Friendship.Followers" = "关注者"; @@ -210,14 +214,20 @@ "Common.Controls.Status.Actions.CopyLink" = "复制链接"; "Common.Controls.Status.Actions.CopyText" = "复制文本"; "Common.Controls.Status.Actions.DeleteTweet" = "删除推文"; +"Common.Controls.Status.Actions.Like" = "喜欢"; "Common.Controls.Status.Actions.PinOnProfile" = "在个人资料页面置顶"; "Common.Controls.Status.Actions.Quote" = "引用"; +"Common.Controls.Status.Actions.Reply" = "回复"; +"Common.Controls.Status.Actions.Repost" = "转发"; "Common.Controls.Status.Actions.Retweet" = "转推"; "Common.Controls.Status.Actions.SaveMedia" = "保存媒体"; "Common.Controls.Status.Actions.Share" = "分享"; "Common.Controls.Status.Actions.ShareContent" = "分享内容"; "Common.Controls.Status.Actions.ShareLink" = "分享链接"; "Common.Controls.Status.Actions.Translate" = "翻译"; +"Common.Controls.Status.Actions.UndoBoost" = "撤消转嘟"; +"Common.Controls.Status.Actions.UndoRepost" = "撤消转发"; +"Common.Controls.Status.Actions.UndoRetweet" = "撤消转推"; "Common.Controls.Status.Actions.UnpinFromProfile" = "在个人资料页面取消置顶"; "Common.Controls.Status.Actions.Vote" = "投票"; "Common.Controls.Status.Media" = "媒体"; @@ -274,6 +284,11 @@ "Common.NotificationChannel.ContentMessages.Name" = "私信"; "Scene.Authentication.Title" = "身份认证"; "Scene.Bookmark.Title" = "书签"; +"Scene.Column.Actions.CloseColumn" = "关闭分栏"; +"Scene.Column.Actions.MoveLeft" = "左移"; +"Scene.Column.Actions.MoveRight" = "右移"; +"Scene.Column.Actions.OpenInNewColumn" = "在新分栏中打开"; +"Scene.Column.Title" = "创建分栏"; "Scene.Compose.And" = ","; "Scene.Compose.CwPlaceholder" = "折叠部分的警告消息"; "Scene.Compose.LastEnd" = " 和 "; @@ -314,6 +329,7 @@ "Scene.Compose.Vote.PlaceholderIndex" = "选项 %d"; "Scene.ComposeHashtagSearch.SearchPlaceholder" = "搜索标签"; "Scene.ComposeUserSearch.SearchPlaceholder" = "搜索用户"; +"Scene.Detail.Title" = "详情"; "Scene.Drafts.Actions.DeleteDraft" = "删除草稿"; "Scene.Drafts.Actions.EditDraft" = "编辑草稿"; "Scene.Drafts.Title" = "草稿"; @@ -322,6 +338,11 @@ "Scene.Federated.Title" = "跨站"; "Scene.Followers.Title" = "关注者"; "Scene.Following.Title" = "已关注"; +"Scene.History.Clear" = "清除"; +"Scene.History.Scope.Toot" = "嘟文"; +"Scene.History.Scope.Tweet" = "推文"; +"Scene.History.Scope.User" = "用户"; +"Scene.History.Title" = "历史"; "Scene.Likes.Title" = "喜欢"; "Scene.Listed.Title" = "列表中"; "Scene.Lists.Icons.Create" = "创建列表"; @@ -360,6 +381,7 @@ "Scene.ListsUsers.MenuActions.Remove" = "删除"; "Scene.Local.Title" = "本站"; "Scene.ManageAccounts.DeleteAccount" = "删除帐号"; +"Scene.ManageAccounts.OpenInNewWindow" = "在新窗口中打开"; "Scene.ManageAccounts.Title" = "帐号"; "Scene.Mentions.Title" = "提及"; "Scene.Messages.Action.CopyText" = "复制消息文本"; @@ -398,7 +420,6 @@ "Scene.Settings.About.Logo.BackgroundShadow" = "关于页面背景徽标阴影"; "Scene.Settings.About.Title" = "关于"; "Scene.Settings.About.Version" = "版本 %@"; -"Scene.Settings.Account.AccountSettings" = "账户设置"; "Scene.Settings.Account.BlockedPeople" = "已屏蔽用户"; "Scene.Settings.Account.MuteAndBlock" = "静音和屏蔽"; "Scene.Settings.Account.MutedPeople" = "已静音用户"; @@ -425,6 +446,22 @@ "Scene.Settings.Appearance.Translation.Off" = "关闭"; "Scene.Settings.Appearance.Translation.Service" = "服务"; "Scene.Settings.Appearance.Translation.TranslateButton" = "翻译按钮"; +"Scene.Settings.Behaviors.HistorySection.EnableHistoryRecord" = "启用历史记录"; +"Scene.Settings.Behaviors.HistorySection.History" = "历史"; +"Scene.Settings.Behaviors.TabBarSection.ShowTabBarLabels" = "显示标签栏文本"; +"Scene.Settings.Behaviors.TabBarSection.TabBar" = "标签栏"; +"Scene.Settings.Behaviors.TabBarSection.TapTabBarScrollToTop" = "点击标签栏时滚动到顶部"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.AutomaticallyRefreshTimeline" = "自动刷新时间线"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshInterval" = "刷新间隔"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.120Seconds" = "120 秒"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.300Seconds" = "300 秒"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.30Seconds" = "30 秒"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption.60Seconds" = "60 秒"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTop" = "重置到顶部"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTopOption.DoubleTap" = "双击"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTopOption.SingleTap" = "单击"; +"Scene.Settings.Behaviors.TimelineRefreshingSection.TimelineRefreshing" = "时间线刷新"; +"Scene.Settings.Behaviors.Title" = "行为"; "Scene.Settings.Display.DateFormat.Absolute" = "绝对的"; "Scene.Settings.Display.DateFormat.Relative" = "相对的"; "Scene.Settings.Display.Media.Always" = "总是"; @@ -434,6 +471,7 @@ "Scene.Settings.Display.Media.MuteByDefault" = "默认静音"; "Scene.Settings.Display.Media.Off" = "关闭"; "Scene.Settings.Display.Preview.ThankForUsingTwidereX" = "感谢您使用 @TwidereProject !"; +"Scene.Settings.Display.SectionHeader.Avatar" = "头像"; "Scene.Settings.Display.SectionHeader.DateFormat" = "时间格式"; "Scene.Settings.Display.SectionHeader.Media" = "媒体"; "Scene.Settings.Display.SectionHeader.Preview" = "预览"; @@ -481,6 +519,10 @@ "Scene.Settings.Notification.NotificationSwitch" = "显示通知"; "Scene.Settings.Notification.PushNotification" = "推送通知"; "Scene.Settings.Notification.Title" = "通知"; +"Scene.Settings.PrivacyAndSafety.AlwaysShowSensitiveMedia" = "始终显示敏感媒体内容"; +"Scene.Settings.PrivacyAndSafety.SectionHeader.MuteAndBlock" = "静音和屏蔽"; +"Scene.Settings.PrivacyAndSafety.SectionHeader.Sensitive" = "敏感内容"; +"Scene.Settings.PrivacyAndSafety.Title" = "隐私和安全"; "Scene.Settings.SectionHeader.About" = "关于"; "Scene.Settings.SectionHeader.Account" = "账号"; "Scene.Settings.SectionHeader.General" = "通用"; diff --git a/TwidereSDK/Sources/TwidereUI/Banner/NotificationBannerView.swift b/TwidereSDK/Sources/TwidereUI/Banner/NotificationBannerView.swift index a6028da1..b552549f 100644 --- a/TwidereSDK/Sources/TwidereUI/Banner/NotificationBannerView.swift +++ b/TwidereSDK/Sources/TwidereUI/Banner/NotificationBannerView.swift @@ -10,7 +10,6 @@ import os.log import UIKit import SwiftUI import TwidereAsset -import TwidereCommon public final class NotificationBannerView: UIView { @@ -23,6 +22,7 @@ public final class NotificationBannerView: UIView { let imageView = UIImageView() imageView.image = Asset.Indices.exclamationmarkCircle.image.withRenderingMode(.alwaysTemplate) imageView.tintColor = .white + imageView.contentMode = .scaleAspectFit return imageView }() diff --git a/TwidereSDK/Sources/TwidereUI/Button/FollowButton.swift b/TwidereSDK/Sources/TwidereUI/Button/FollowButton.swift new file mode 100644 index 00000000..7942f8a6 --- /dev/null +++ b/TwidereSDK/Sources/TwidereUI/Button/FollowButton.swift @@ -0,0 +1,69 @@ +// +// FollowButton.swift +// +// +// Created by MainasuK on 2023/4/13. +// + +import SwiftUI +import CoreDataStack + +public struct FollowButton: View { + + @ObservedObject public var viewModel: ViewModel + + public var body: some View { + Button { + + } label: { + Text("Follow") + } + .buttonStyle(.borderless) + } +} + +extension FollowButton { + public class ViewModel: ObservableObject { + + // input + public let user: UserObject + public let authContext: AuthContext + + // output + + + public init( + user: UserObject, + authContext: AuthContext + ) { + self.user = user + self.authContext = authContext + // end init + } + + } +} + +extension FollowButton.ViewModel { + public convenience init( + user: TwitterUser, + authContext: AuthContext + ) { + self.init( + user: .twitter(object: user), + authContext: authContext + ) + // end init + } + + public convenience init( + user: MastodonUser, + authContext: AuthContext + ) { + self.init( + user: .mastodon(object: user), + authContext: authContext + ) + // end init + } +} 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+ViewModel.swift b/TwidereSDK/Sources/TwidereUI/Container/MediaGridContainerView+ViewModel.swift deleted file mode 100644 index 09e6dfa3..00000000 --- a/TwidereSDK/Sources/TwidereUI/Container/MediaGridContainerView+ViewModel.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// MediaGridContainerView+ViewModel.swift -// -// -// Created by MainasuK on 2021-12-14. -// - -import UIKit -import Combine - -extension MediaGridContainerView { - public class ViewModel { - var disposeBag = Set() - - @Published public var isSensitiveToggleButtonDisplay: Bool = false - @Published public var isContentWarningOverlayDisplay: Bool? = nil - } -} - -extension MediaGridContainerView.ViewModel { - - func resetContentWarningOverlay() { - isContentWarningOverlayDisplay = nil - } - - func bind(view: MediaGridContainerView) { - $isSensitiveToggleButtonDisplay - .sink { isDisplay in - view.sensitiveToggleButtonBlurVisualEffectView.isHidden = !isDisplay - } - .store(in: &disposeBag) - $isContentWarningOverlayDisplay - .sink { isDisplay in - assert(Thread.isMainThread) - guard let isDisplay = isDisplay else { return } - let withAnimation = self.isContentWarningOverlayDisplay != nil - view.configureOverlayDisplay(isDisplay: isDisplay, animated: withAnimation) - } - .store(in: &disposeBag) - } - -} - -extension MediaGridContainerView { - func configureOverlayDisplay(isDisplay: Bool, animated: Bool) { - if animated { - UIView.animate(withDuration: 0.33, delay: 0, options: .curveEaseInOut) { - self.contentWarningOverlayView.blurVisualEffectView.alpha = isDisplay ? 1 : 0 - } - } else { - contentWarningOverlayView.blurVisualEffectView.alpha = isDisplay ? 1 : 0 - } - - contentWarningOverlayView.isUserInteractionEnabled = isDisplay - contentWarningOverlayView.tapGestureRecognizer.isEnabled = isDisplay - } -} diff --git a/TwidereSDK/Sources/TwidereUI/Container/MediaGridContainerView.swift b/TwidereSDK/Sources/TwidereUI/Container/MediaGridContainerView.swift index 4ab3117a..979989f2 100644 --- a/TwidereSDK/Sources/TwidereUI/Container/MediaGridContainerView.swift +++ b/TwidereSDK/Sources/TwidereUI/Container/MediaGridContainerView.swift @@ -8,349 +8,761 @@ import os.log import UIKit -import func AVFoundation.AVMakeRect +import SwiftUI -public protocol MediaGridContainerViewDelegate: AnyObject { - func mediaGridContainerView(_ container: MediaGridContainerView, didTapMediaView mediaView: MediaView, at index: Int) - func mediaGridContainerView(_ container: MediaGridContainerView, toggleContentWarningOverlayViewDisplay contentWarningOverlayView: ContentWarningOverlayView) -} - -public final class MediaGridContainerView: UIView { +public struct MediaGridContainerView: View { - public static let maxCount = 9 + static public var spacing: CGFloat { 8 } + static public var cornerRadius: CGFloat { 8 } - let logger = Logger(subsystem: "MediaGridContainerView", category: "UI") + public let viewModels: [MediaView.ViewModel] - public weak var delegate: MediaGridContainerViewDelegate? - public private(set) lazy var viewModel: ViewModel = { - let viewModel = ViewModel() - viewModel.bind(view: self) - return viewModel - }() + public let idealWidth: CGFloat? + public let idealHeight: CGFloat // ideal height for grid exclude single media - // lazy var is required here to setup gesture recognizer target-action - // Swift not doesn't emit compiler error if without `lazy` here - private(set) lazy var _mediaViews: [MediaView] = { - var mediaViews: [MediaView] = [] - for i in 0.. Void - let sensitiveToggleButtonBlurVisualEffectView: UIVisualEffectView = { - let visualEffectView = UIVisualEffectView(effect: ContentWarningOverlayView.blurVisualEffect) - visualEffectView.layer.masksToBounds = true - visualEffectView.layer.cornerRadius = 6 - visualEffectView.layer.cornerCurve = .continuous - return visualEffectView - }() - let sensitiveToggleButtonVibrancyVisualEffectView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: ContentWarningOverlayView.blurVisualEffect)) - let sensitiveToggleButton: HitTestExpandedButton = { - let button = HitTestExpandedButton(type: .system) - button.setImage(Asset.Human.eyeSlashMini.image.withRenderingMode(.alwaysTemplate), for: .normal) - button.isAccessibilityElement = true - button.accessibilityLabel = L10n.Accessibility.Common.Status.Actions.hideMedia - button.accessibilityTraits = .button - return button - }() - - public let contentWarningOverlayView: ContentWarningOverlayView = { - let overlay = ContentWarningOverlayView() - overlay.layer.masksToBounds = true - overlay.layer.cornerRadius = MediaView.cornerRadius - overlay.layer.cornerCurve = .continuous - overlay.isAccessibilityElement = true - overlay.accessibilityLabel = L10n.Accessibility.Common.Status.Actions.revealMedia - overlay.accessibilityTraits = .button - return overlay - }() - - public override init(frame: CGRect) { - super.init(frame: frame) - _init() - } - - public required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } - -} - -extension MediaGridContainerView { - private func _init() { - sensitiveToggleButton.addTarget(self, action: #selector(MediaGridContainerView.sensitiveToggleButtonDidPressed(_:)), for: .touchUpInside) - contentWarningOverlayView.delegate = self - } -} - -extension MediaGridContainerView { - @objc private func mediaViewTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { - guard let index = _mediaViews.firstIndex(where: { $0.container === sender.view }) else { return } - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(index)") - let mediaView = _mediaViews[index] - delegate?.mediaGridContainerView(self, didTapMediaView: mediaView, at: index) - } - - @objc private func sensitiveToggleButtonDidPressed(_ sender: UIButton) { - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") - delegate?.mediaGridContainerView(self, toggleContentWarningOverlayViewDisplay: contentWarningOverlayView) - } + public var body: some View { + VStack { + switch viewModels.count { + case 1: + mediaView(at: 0, width: idealWidth, height: idealHeight) + case 2: + let height = height(for: 1) + HStack(spacing: MediaGridContainerView.spacing) { + mediaView(at: 0, width: nil, height: height) + mediaView(at: 1, width: nil, height: height) + } + case 3: + HStack(alignment: .top, spacing: MediaGridContainerView.spacing) { + let height = height(for: 2) + mediaView(at: 0, width: nil, height: 2 * height + MediaGridContainerView.spacing) + VStack(spacing: MediaGridContainerView.spacing) { + mediaView(at: 1, width: nil, height: height) + mediaView(at: 2, width: nil, height: height) + } + } + case 4: + HStack(alignment: .top, spacing: MediaGridContainerView.spacing) { + let height = height(for: 2) + VStack(spacing: MediaGridContainerView.spacing) { + mediaView(at: 0, width: nil, height: height) + mediaView(at: 2, width: nil, height: height) + } + VStack(spacing: MediaGridContainerView.spacing) { + mediaView(at: 1, width: nil, height: height) + mediaView(at: 3, width: nil, height: height) + } + } + case 5: + HStack(alignment: .top, spacing: MediaGridContainerView.spacing) { + let height = height(for: 3) + VStack(spacing: MediaGridContainerView.spacing) { + mediaView(at: 0, width: nil, height: height) + mediaView(at: 3, width: nil, height: height) + } + VStack(spacing: MediaGridContainerView.spacing) { + mediaView(at: 1, width: nil, height: height) + mediaView(at: 4, width: nil, height: height) + } + VStack(spacing: MediaGridContainerView.spacing) { + mediaView(at: 2, width: nil, height: height) + } + } + case 6: + HStack(alignment: .top, spacing: MediaGridContainerView.spacing) { + let height = height(for: 3) + VStack(spacing: MediaGridContainerView.spacing) { + mediaView(at: 0, width: nil, height: height) + mediaView(at: 3, width: nil, height: height) + } + VStack(spacing: MediaGridContainerView.spacing) { + mediaView(at: 1, width: nil, height: height) + mediaView(at: 4, width: nil, height: height) + } + VStack(spacing: MediaGridContainerView.spacing) { + mediaView(at: 2, width: nil, height: height) + mediaView(at: 5, width: nil, height: height) + } + } + case 7: + HStack(alignment: .top, spacing: MediaGridContainerView.spacing) { + let height = height(for: 3) + VStack(spacing: MediaGridContainerView.spacing) { + mediaView(at: 0, width: nil, height: height) + mediaView(at: 3, width: nil, height: height) + mediaView(at: 6, width: nil, height: height) + } + VStack(spacing: MediaGridContainerView.spacing) { + mediaView(at: 1, width: nil, height: height) + mediaView(at: 4, width: nil, height: height) + } + VStack(spacing: MediaGridContainerView.spacing) { + mediaView(at: 2, width: nil, height: height) + mediaView(at: 5, width: nil, height: height) + } + } + case 8: + HStack(alignment: .top, spacing: MediaGridContainerView.spacing) { + let height = height(for: 3) + VStack(spacing: MediaGridContainerView.spacing) { + mediaView(at: 0, width: nil, height: height) + mediaView(at: 3, width: nil, height: height) + mediaView(at: 6, width: nil, height: height) + } + VStack(spacing: MediaGridContainerView.spacing) { + mediaView(at: 1, width: nil, height: height) + mediaView(at: 4, width: nil, height: height) + mediaView(at: 7, width: nil, height: height) + } + VStack(spacing: MediaGridContainerView.spacing) { + mediaView(at: 2, width: nil, height: height) + mediaView(at: 5, width: nil, height: height) + } + } + case 9...: + HStack(alignment: .top, spacing: MediaGridContainerView.spacing) { + let height = height(for: 3) + VStack(spacing: MediaGridContainerView.spacing) { + mediaView(at: 0, width: nil, height: height) + mediaView(at: 3, width: nil, height: height) + mediaView(at: 6, width: nil, height: height) + } + VStack(spacing: MediaGridContainerView.spacing) { + mediaView(at: 1, width: nil, height: height) + mediaView(at: 4, width: nil, height: height) + mediaView(at: 7, width: nil, height: height) + } + VStack(spacing: MediaGridContainerView.spacing) { + mediaView(at: 2, width: nil, height: height) + mediaView(at: 5, width: nil, height: height) + mediaView(at: 8, width: nil, height: height) + .overlay() { + let remains = viewModels.count - 9 + if remains > 0 { + Color.black.opacity(0.3) + .overlay { + Text("+\(remains)") + .font(.system(size: 27, weight: .semibold, design: .default)) + .foregroundColor(.white) + } + } // end if + } + } + } + default: + EmptyView() + } + } // end Group + } // end body } extension MediaGridContainerView { - - public func dequeueMediaView(adaptiveLayout layout: AdaptiveLayout) -> MediaView { - prepareForReuse() - - let mediaView = _mediaViews[0] - layout.layout(in: self, mediaView: mediaView) - - layoutSensitiveToggleButton() - bringSubviewToFront(sensitiveToggleButtonBlurVisualEffectView) - - layoutContentOverlayView(on: mediaView) - bringSubviewToFront(contentWarningOverlayView) - - return mediaView - } - public func dequeueMediaView(gridLayout layout: GridLayout) -> [MediaView] { - prepareForReuse() - - let mediaViews = Array(_mediaViews[0.. some View { + Group { + let viewModel = viewModels[index] + switch viewModels.count { + case 1: + MediaView(viewModel: viewModel) + .modifier(MediaViewFrameModifer( + asepctRatio: viewModel.aspectRatio.width / viewModel.aspectRatio.height, + idealWidth: idealWidth, + idealHeight: viewModel.mediaKind == .video ? idealHeight : 2 * idealHeight) + ) + default: + Rectangle() + .fill(.clear) + .frame(width: width, height: height) + .overlay( + MediaView(viewModel: viewModel) + .aspectRatio(contentMode: .fill) + ) + } } - - subviews.forEach { view in - view.removeFromSuperview() + .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 + } + }) + .overlay(alignment: .bottom) { + MediaMetaIndicatorView(viewModel: viewModels[index]) } - - removeConstraints(constraints) - } - -} - -extension MediaGridContainerView { - private func layoutSensitiveToggleButton() { - sensitiveToggleButtonBlurVisualEffectView.translatesAutoresizingMaskIntoConstraints = false - addSubview(sensitiveToggleButtonBlurVisualEffectView) - NSLayoutConstraint.activate([ - sensitiveToggleButtonBlurVisualEffectView.topAnchor.constraint(equalTo: topAnchor, constant: 8), - sensitiveToggleButtonBlurVisualEffectView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), - ]) - - sensitiveToggleButtonVibrancyVisualEffectView.translatesAutoresizingMaskIntoConstraints = false - sensitiveToggleButtonBlurVisualEffectView.contentView.addSubview(sensitiveToggleButtonVibrancyVisualEffectView) - NSLayoutConstraint.activate([ - sensitiveToggleButtonVibrancyVisualEffectView.topAnchor.constraint(equalTo: sensitiveToggleButtonBlurVisualEffectView.contentView.topAnchor), - sensitiveToggleButtonVibrancyVisualEffectView.leadingAnchor.constraint(equalTo: sensitiveToggleButtonBlurVisualEffectView.contentView.leadingAnchor), - sensitiveToggleButtonVibrancyVisualEffectView.trailingAnchor.constraint(equalTo: sensitiveToggleButtonBlurVisualEffectView.contentView.trailingAnchor), - sensitiveToggleButtonVibrancyVisualEffectView.bottomAnchor.constraint(equalTo: sensitiveToggleButtonBlurVisualEffectView.contentView.bottomAnchor), - ]) - - sensitiveToggleButton.translatesAutoresizingMaskIntoConstraints = false - sensitiveToggleButtonVibrancyVisualEffectView.contentView.addSubview(sensitiveToggleButton) - NSLayoutConstraint.activate([ - sensitiveToggleButton.topAnchor.constraint(equalTo: sensitiveToggleButtonVibrancyVisualEffectView.contentView.topAnchor, constant: 4), - sensitiveToggleButton.leadingAnchor.constraint(equalTo: sensitiveToggleButtonVibrancyVisualEffectView.contentView.leadingAnchor, constant: 4), - sensitiveToggleButtonVibrancyVisualEffectView.contentView.trailingAnchor.constraint(equalTo: sensitiveToggleButton.trailingAnchor, constant: 4), - sensitiveToggleButtonVibrancyVisualEffectView.contentView.bottomAnchor.constraint(equalTo: sensitiveToggleButton.bottomAnchor, constant: 4), - ]) - } + .overlay( + RoundedRectangle(cornerRadius: MediaGridContainerView.cornerRadius) + .stroke(Color(uiColor: .placeholderText).opacity(0.5), lineWidth: 1) + ) + .contentShape(Rectangle()) + .onTapGesture { + let viewModel = viewModels[index] + handler(viewModel, MediaView.ViewModel.Action.preview) + } + .contextMenu(contextMenuContentPreviewProvider: { + let viewModel = viewModels[index] + guard let thumbnail = viewModel.thumbnail else { return nil } + let contextMenuImagePreviewViewModel = ContextMenuImagePreviewViewModel(aspectRatio: thumbnail.size, thumbnail: thumbnail) + let previewProvider = ContextMenuImagePreviewViewController() + previewProvider.viewModel = contextMenuImagePreviewViewModel + return previewProvider + + }, contextMenuActionProvider: { _ in + let viewModel = viewModels[index] + let shareChildren: [UIAction] = [ + UIAction( + title: MediaView.ViewModel.Action.shareLink.title, + image: MediaView.ViewModel.Action.shareLink.image, + attributes: [], + state: .off + ) { _ in + handler(viewModel, .shareLink) + }, + UIAction( + title: MediaView.ViewModel.Action.shareMedia.title, + image: MediaView.ViewModel.Action.shareMedia.image, + attributes: [], + state: .off + ) { _ in + handler(viewModel, .shareMedia) + }, + ] + let children: [UIMenuElement] = [ + UIAction( + title: MediaView.ViewModel.Action.save.title, + image: MediaView.ViewModel.Action.save.image, + attributes: [], + state: .off + ) { _ in + handler(viewModel, .save) + }, + UIAction( + title: MediaView.ViewModel.Action.copy.title, + image: MediaView.ViewModel.Action.copy.image, + attributes: [], + state: .off + ) { _ in + handler(viewModel, .copy) + }, + UIMenu( + title: L10n.Common.Controls.Actions.share, + image: UIImage(systemName: "square.and.arrow.up"), + identifier: nil, + options: [], + children: shareChildren + ), + ] + return UIMenu(title: "", image: nil, identifier: nil, options: [], children: children) + }, previewActionWithContext: { context in + let viewModel = viewModels[index] + handler(viewModel, MediaView.ViewModel.Action.previewWithContext(context)) + }) + } // end func - private func layoutContentOverlayView(on view: UIView) { - contentWarningOverlayView.translatesAutoresizingMaskIntoConstraints = false - addSubview(contentWarningOverlayView) // should add to container - NSLayoutConstraint.activate([ - contentWarningOverlayView.topAnchor.constraint(equalTo: view.topAnchor), - contentWarningOverlayView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - contentWarningOverlayView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - contentWarningOverlayView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ]) + private func height(for rows: Int) -> CGFloat { + guard let idealWidth = self.idealWidth else { + // fix grid height + let margins = CGFloat(rows - 1) * MediaGridContainerView.spacing + let height = (idealHeight - margins) / CGFloat(rows) + return height + } + + // make tiem square + let cols = rows < 3 ? 2 : 3 + let margins = CGFloat(cols - 1) * MediaGridContainerView.spacing + let width = (idealWidth - margins) / CGFloat(cols) + return width } } -extension MediaGridContainerView { +public struct MediaViewFrameModifer: ViewModifier { - public var mediaViews: [MediaView] { - _mediaViews.filter { $0.superview != nil } - } - - public func setAlpha(_ alpha: CGFloat) { - _mediaViews.forEach { $0.alpha = alpha } + let asepctRatio: CGFloat? + let idealWidth: CGFloat? + let idealHeight: CGFloat + + public init( + asepctRatio: CGFloat?, + idealWidth: CGFloat?, + idealHeight: CGFloat + ) { + self.asepctRatio = asepctRatio + self.idealWidth = idealWidth + self.idealHeight = idealHeight } - public func setAlpha(_ alpha: CGFloat, index: Int) { - if index < _mediaViews.count { - _mediaViews[index].alpha = alpha + public func body(content: Content) -> some View { + if let idealWidth = idealWidth { + let minHeight: CGFloat = 44 + let maxHeight = ceil(3 * idealWidth) + let height = min(maxHeight, max(minHeight, idealWidth / (asepctRatio ?? 1.0))) + content + .frame(width: idealWidth, height: height) + } else { + content + .frame(maxHeight: idealHeight) } } - } -extension MediaGridContainerView { - public struct AdaptiveLayout { - let aspectRatio: CGSize - let maxSize: CGSize - - func layout(in view: UIView, mediaView: MediaView) { - let imageViewSize = AVMakeRect(aspectRatio: aspectRatio, insideRect: CGRect(origin: .zero, size: maxSize)).size - mediaView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(mediaView) - NSLayoutConstraint.activate([ - mediaView.topAnchor.constraint(equalTo: view.topAnchor), - mediaView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - mediaView.trailingAnchor.constraint(equalTo: view.trailingAnchor).priority(.defaultLow), - mediaView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - mediaView.widthAnchor.constraint(equalToConstant: imageViewSize.width).priority(.required - 1), - mediaView.heightAnchor.constraint(equalToConstant: imageViewSize.height).priority(.required - 1), - ]) - } - } +struct MediaGridContainerView_Previews: PreviewProvider { - public struct GridLayout { - static let spacing: CGFloat = 8 - - let count: Int - let maxSize: CGSize - - init(count: Int, maxSize: CGSize) { - self.count = min(count, 9) - self.maxSize = maxSize - - } - - private func createStackView(axis: NSLayoutConstraint.Axis) -> UIStackView { - let stackView = UIStackView() - stackView.axis = axis - stackView.semanticContentAttribute = .forceLeftToRight - stackView.spacing = GridLayout.spacing - stackView.distribution = .fillEqually - return stackView - } - - public func layout(in view: UIView, mediaViews: [MediaView]) { - let containerVerticalStackView = createStackView(axis: .vertical) - containerVerticalStackView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(containerVerticalStackView) - NSLayoutConstraint.activate([ - containerVerticalStackView.topAnchor.constraint(equalTo: view.topAnchor), - containerVerticalStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - containerVerticalStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - containerVerticalStackView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ]) - - let count = mediaViews.count - switch count { - case 1: - assertionFailure("should use Adaptive Layout") - containerVerticalStackView.addArrangedSubview(mediaViews[0]) - case 2: - let horizontalStackView = createStackView(axis: .horizontal) - containerVerticalStackView.addArrangedSubview(horizontalStackView) - horizontalStackView.addArrangedSubview(mediaViews[0]) - horizontalStackView.addArrangedSubview(mediaViews[1]) - case 3: - let horizontalStackView = createStackView(axis: .horizontal) - containerVerticalStackView.addArrangedSubview(horizontalStackView) - horizontalStackView.addArrangedSubview(mediaViews[0]) - - let verticalStackView = createStackView(axis: .vertical) - horizontalStackView.addArrangedSubview(verticalStackView) - verticalStackView.addArrangedSubview(mediaViews[1]) - verticalStackView.addArrangedSubview(mediaViews[2]) - case 4: - let topHorizontalStackView = createStackView(axis: .horizontal) - containerVerticalStackView.addArrangedSubview(topHorizontalStackView) - topHorizontalStackView.addArrangedSubview(mediaViews[0]) - topHorizontalStackView.addArrangedSubview(mediaViews[1]) - - let bottomHorizontalStackView = createStackView(axis: .horizontal) - containerVerticalStackView.addArrangedSubview(bottomHorizontalStackView) - bottomHorizontalStackView.addArrangedSubview(mediaViews[2]) - bottomHorizontalStackView.addArrangedSubview(mediaViews[3]) - case 5...9: - let topHorizontalStackView = createStackView(axis: .horizontal) - containerVerticalStackView.addArrangedSubview(topHorizontalStackView) - topHorizontalStackView.addArrangedSubview(mediaViews[0]) - topHorizontalStackView.addArrangedSubview(mediaViews[1]) - topHorizontalStackView.addArrangedSubview(mediaViews[2]) - - func mediaViewOrPlaceholderView(at index: Int) -> UIView { - return index < mediaViews.count ? mediaViews[index] : UIView() - } - let middleHorizontalStackView = createStackView(axis: .horizontal) - containerVerticalStackView.addArrangedSubview(middleHorizontalStackView) - middleHorizontalStackView.addArrangedSubview(mediaViews[3]) - middleHorizontalStackView.addArrangedSubview(mediaViews[4]) - middleHorizontalStackView.addArrangedSubview(mediaViewOrPlaceholderView(at: 5)) - - if count > 6 { - let bottomHorizontalStackView = createStackView(axis: .horizontal) - containerVerticalStackView.addArrangedSubview(bottomHorizontalStackView) - bottomHorizontalStackView.addArrangedSubview(mediaViewOrPlaceholderView(at: 6)) - bottomHorizontalStackView.addArrangedSubview(mediaViewOrPlaceholderView(at: 7)) - bottomHorizontalStackView.addArrangedSubview(mediaViewOrPlaceholderView(at: 8)) - } - default: - assertionFailure() - return + static let viewModels = { + let models = [ + MediaView.ViewModel( + mediaKind: .photo, + aspectRatio: CGSize(width: 2048, height: 1186), + altText: nil, + previewURL: URL(string: "https://www.nasa.gov/sites/default/files/thumbnails/image/web_first_images_release.png"), + assetURL: URL(string: "https://www.nasa.gov/sites/default/files/thumbnails/image/web_first_images_release.png"), + downloadURL: URL(string: "https://www.nasa.gov/sites/default/files/thumbnails/image/web_first_images_release.png"), + durationMS: nil + ), + MediaView.ViewModel( + mediaKind: .photo, + aspectRatio: CGSize(width: 6096, height: 5173), + altText: nil, + previewURL: URL(string: "https://www.nasa.gov/sites/default/files/thumbnails/image/chandra_ixpe_v3magentahires.jpg"), + assetURL: URL(string: "https://www.nasa.gov/sites/default/files/thumbnails/image/chandra_ixpe_v3magentahires.jpg"), + downloadURL: URL(string: "https://www.nasa.gov/sites/default/files/thumbnails/image/chandra_ixpe_v3magentahires.jpg"), + durationMS: nil + ), + MediaView.ViewModel( + mediaKind: .animatedGIF, + aspectRatio: CGSize(width: 1200, height: 720), + altText: nil, + previewURL: URL(string: "https://media.mstdn.jp/cache/media_attachments/files/109/936/306/341/672/302/small/19d1c52c1a1713b2.png"), + assetURL: URL(string: "https://media.mstdn.jp/cache/media_attachments/files/109/936/306/341/672/302/small/19d1c52c1a1713b2.png"), + downloadURL: URL(string: "https://media.mstdn.jp/cache/media_attachments/files/109/936/306/341/672/302/original/19d1c52c1a1713b2.mp4"), + durationMS: 11084 + ), + MediaView.ViewModel( + mediaKind: .video, + aspectRatio: CGSize(width: 1200, height: 675), + altText: nil, + previewURL: URL(string: "https://pbs.twimg.com/ext_tw_video_thumb/1629081362555899904/pu/img/em5qGBhoV0R1aGfv.jpg"), + assetURL: URL(string: "https://pbs.twimg.com/ext_tw_video_thumb/1629081362555899904/pu/img/em5qGBhoV0R1aGfv.jpg"), + downloadURL: URL(string: "https://video.twimg.com/ext_tw_video/1629081362555899904/pu/vid/1280x720/4OGsKDg67adqojtX.mp4?tag=12"), + durationMS: 10555 + ), + MediaView.ViewModel( + mediaKind: .photo, + aspectRatio: CGSize(width: 2016, height: 2016), + altText: nil, + previewURL: URL(string: "https://www.nasa.gov/sites/default/files/images/671506main_PIA15628_full.jpg"), + assetURL: URL(string: "https://www.nasa.gov/sites/default/files/images/671506main_PIA15628_full.jpg"), + downloadURL: URL(string: "https://www.nasa.gov/sites/default/files/images/671506main_PIA15628_full.jpg"), + durationMS: nil + ), + MediaView.ViewModel( + mediaKind: .photo, + aspectRatio: CGSize(width: 3482, height: 1959), + altText: nil, + previewURL: URL(string: "https://www.nasa.gov/sites/default/files/thumbnails/image/pia20027_updated.jpg"), + assetURL: URL(string: "https://www.nasa.gov/sites/default/files/thumbnails/image/pia20027_updated.jpg"), + downloadURL: URL(string: "https://www.nasa.gov/sites/default/files/thumbnails/image/pia20027_updated.jpg"), + durationMS: nil + ), + ] + return Array(repeating: models, count: 3).flatMap { $0 } + }() + + static var previews: some View { + Group { + ForEach(0.. 6 ? containerWidth : containerWidth * 2 / 3 - NSLayoutConstraint.activate([ - view.widthAnchor.constraint(equalToConstant: containerWidth).priority(.required - 1), - view.heightAnchor.constraint(equalToConstant: containerHeight).priority(.required - 1), - ]) } } } -// MARK: - ContentWarningOverlayViewDelegate -extension MediaGridContainerView: ContentWarningOverlayViewDelegate { - public func contentWarningOverlayViewDidPressed(_ contentWarningOverlayView: ContentWarningOverlayView) { - delegate?.mediaGridContainerView(self, toggleContentWarningOverlayViewDisplay: contentWarningOverlayView) +extension View { + func contextMenu( + contextMenuContentPreviewProvider: @escaping UIContextMenuContentPreviewProvider, + contextMenuActionProvider: @escaping UIContextMenuActionProvider, + previewActionWithContext: @escaping (ContextMenuInteractionPreviewActionContext) -> Void + ) -> some View { + modifier(ContextMenuViewModifier( + contextMenuContentPreviewProvider: contextMenuContentPreviewProvider, + contextMenuActionProvider: contextMenuActionProvider, + previewActionWithContext: previewActionWithContext + )) } } -extension MediaGridContainerView { - public override var accessibilityElements: [Any]? { - get { - if viewModel.isContentWarningOverlayDisplay == true { - return [contentWarningOverlayView] - } else { - return [sensitiveToggleButton] + mediaViews - } - +struct ContextMenuViewModifier: ViewModifier { + let contextMenuContentPreviewProvider: UIContextMenuContentPreviewProvider + let contextMenuActionProvider: UIContextMenuActionProvider + let previewActionWithContext: (ContextMenuInteractionPreviewActionContext) -> Void + + func body(content: Content) -> some View { + ContextMenuInteractionRepresentable( + contextMenuContentPreviewProvider: contextMenuContentPreviewProvider, + contextMenuActionProvider: contextMenuActionProvider + ) { + content + } previewActionWithContext: { context in + previewActionWithContext(context) } - set { } } } + + + +//public final class MediaGridContainerView: UIView { +// +// public static let maxCount = 9 +// +// let logger = Logger(subsystem: "MediaGridContainerView", category: "UI") +// +// public weak var delegate: MediaGridContainerViewDelegate? +// public private(set) lazy var viewModel: ViewModel = { +// let viewModel = ViewModel() +// viewModel.bind(view: self) +// return viewModel +// }() +// +// // lazy var is required here to setup gesture recognizer target-action +// // Swift not doesn't emit compiler error if without `lazy` here +// private(set) lazy var _mediaViews: [MediaView] = { +// var mediaViews: [MediaView] = [] +// for i in 0.. MediaView { +// prepareForReuse() +// +// let mediaView = _mediaViews[0] +// layout.layout(in: self, mediaView: mediaView) +// +// layoutSensitiveToggleButton() +// bringSubviewToFront(sensitiveToggleButtonBlurVisualEffectView) +// +// layoutContentOverlayView(on: mediaView) +// bringSubviewToFront(contentWarningOverlayView) +// +// return mediaView +// } +// +// public func dequeueMediaView(gridLayout layout: GridLayout) -> [MediaView] { +// prepareForReuse() +// +// let mediaViews = Array(_mediaViews[0.. UIStackView { +// let stackView = UIStackView() +// stackView.axis = axis +// stackView.semanticContentAttribute = .forceLeftToRight +// stackView.spacing = GridLayout.spacing +// stackView.distribution = .fillEqually +// return stackView +// } +// +// public func layout(in view: UIView, mediaViews: [MediaView]) { +// let containerVerticalStackView = createStackView(axis: .vertical) +// containerVerticalStackView.translatesAutoresizingMaskIntoConstraints = false +// view.addSubview(containerVerticalStackView) +// NSLayoutConstraint.activate([ +// containerVerticalStackView.topAnchor.constraint(equalTo: view.topAnchor), +// containerVerticalStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor), +// containerVerticalStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor), +// containerVerticalStackView.bottomAnchor.constraint(equalTo: view.bottomAnchor), +// ]) +// +// let count = mediaViews.count +// switch count { +// case 1: +// assertionFailure("should use Adaptive Layout") +// containerVerticalStackView.addArrangedSubview(mediaViews[0]) +// case 2: +// let horizontalStackView = createStackView(axis: .horizontal) +// containerVerticalStackView.addArrangedSubview(horizontalStackView) +// horizontalStackView.addArrangedSubview(mediaViews[0]) +// horizontalStackView.addArrangedSubview(mediaViews[1]) +// case 3: +// let horizontalStackView = createStackView(axis: .horizontal) +// containerVerticalStackView.addArrangedSubview(horizontalStackView) +// horizontalStackView.addArrangedSubview(mediaViews[0]) +// +// let verticalStackView = createStackView(axis: .vertical) +// horizontalStackView.addArrangedSubview(verticalStackView) +// verticalStackView.addArrangedSubview(mediaViews[1]) +// verticalStackView.addArrangedSubview(mediaViews[2]) +// case 4: +// let topHorizontalStackView = createStackView(axis: .horizontal) +// containerVerticalStackView.addArrangedSubview(topHorizontalStackView) +// topHorizontalStackView.addArrangedSubview(mediaViews[0]) +// topHorizontalStackView.addArrangedSubview(mediaViews[1]) +// +// let bottomHorizontalStackView = createStackView(axis: .horizontal) +// containerVerticalStackView.addArrangedSubview(bottomHorizontalStackView) +// bottomHorizontalStackView.addArrangedSubview(mediaViews[2]) +// bottomHorizontalStackView.addArrangedSubview(mediaViews[3]) +// case 5...9: +// let topHorizontalStackView = createStackView(axis: .horizontal) +// containerVerticalStackView.addArrangedSubview(topHorizontalStackView) +// topHorizontalStackView.addArrangedSubview(mediaViews[0]) +// topHorizontalStackView.addArrangedSubview(mediaViews[1]) +// topHorizontalStackView.addArrangedSubview(mediaViews[2]) +// +// func mediaViewOrPlaceholderView(at index: Int) -> UIView { +// return index < mediaViews.count ? mediaViews[index] : UIView() +// } +// let middleHorizontalStackView = createStackView(axis: .horizontal) +// containerVerticalStackView.addArrangedSubview(middleHorizontalStackView) +// middleHorizontalStackView.addArrangedSubview(mediaViews[3]) +// middleHorizontalStackView.addArrangedSubview(mediaViews[4]) +// middleHorizontalStackView.addArrangedSubview(mediaViewOrPlaceholderView(at: 5)) +// +// if count > 6 { +// let bottomHorizontalStackView = createStackView(axis: .horizontal) +// containerVerticalStackView.addArrangedSubview(bottomHorizontalStackView) +// bottomHorizontalStackView.addArrangedSubview(mediaViewOrPlaceholderView(at: 6)) +// bottomHorizontalStackView.addArrangedSubview(mediaViewOrPlaceholderView(at: 7)) +// bottomHorizontalStackView.addArrangedSubview(mediaViewOrPlaceholderView(at: 8)) +// } +// default: +// assertionFailure() +// return +// } +// +// let containerWidth = maxSize.width +// let containerHeight = count > 6 ? containerWidth : containerWidth * 2 / 3 +// NSLayoutConstraint.activate([ +// view.widthAnchor.constraint(equalToConstant: containerWidth).priority(.required - 1), +// view.heightAnchor.constraint(equalToConstant: containerHeight).priority(.required - 1), +// ]) +// } +// } +//} +// +//// MARK: - ContentWarningOverlayViewDelegate +//extension MediaGridContainerView: ContentWarningOverlayViewDelegate { +// public func contentWarningOverlayViewDidPressed(_ contentWarningOverlayView: ContentWarningOverlayView) { +// delegate?.mediaGridContainerView(self, toggleContentWarningOverlayViewDisplay: contentWarningOverlayView) +// } +//} +// +//extension MediaGridContainerView { +// public override var accessibilityElements: [Any]? { +// get { +// if viewModel.isContentWarningOverlayDisplay == true { +// return [contentWarningOverlayView] +// } else { +// return [sensitiveToggleButton] + mediaViews +// } +// +// } +// set { } +// } +//} diff --git a/TwidereSDK/Sources/TwidereUI/Container/MediaStackContainerView.swift b/TwidereSDK/Sources/TwidereUI/Container/MediaStackContainerView.swift new file mode 100644 index 00000000..b843376b --- /dev/null +++ b/TwidereSDK/Sources/TwidereUI/Container/MediaStackContainerView.swift @@ -0,0 +1,283 @@ +// +// MediaStackContainerView.swift +// +// +// Created by MainasuK on 2023/4/17. +// + +import SwiftUI +import Kingfisher +import CoverFlowStackScrollView + +public struct MediaStackContainerView: View { + + @ObservedObject public private(set) var viewModel: ViewModel + + public let handler: (MediaView.ViewModel, MediaView.ViewModel.Action) -> Void + + public init( + viewModel: MediaStackContainerView.ViewModel, + handler: @escaping (MediaView.ViewModel, MediaView.ViewModel.Action) -> Void + ) { + self.viewModel = viewModel + self.handler = handler + } + + public var body: some View { + GeometryReader { root in + let dimension = min(root.size.width, root.size.height) + switch viewModel.items.count { + case 1: + VStack { + Spacer() + MediaView(viewModel: viewModel.items[0]) + .frame(width: dimension, height: dimension) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay(alignment: .bottom) { + MediaMetaIndicatorView(viewModel: viewModel.items[0]) + } + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color(uiColor: .placeholderText).opacity(0.5), lineWidth: 1) + ) + .onTapGesture { + handler(viewModel.items[0], .preview) + } + Spacer() + } + default: + CoverFlowStackScrollView { + HStack(spacing: .zero) { + ForEach(Array(viewModel.items.enumerated()), id: \.0) { index, item in + mediaView(viewModel: item, at: index, dimension: dimension) + } // end ForEach + } // HStack + } contentOffsetDidUpdate: { contentOffset in + viewModel.contentOffset = contentOffset + } contentSizeDidUpdate: { contentSize in + viewModel.contentSize = contentSize + } // end CoverFlowStackScrollView + } // end switch + } // end GeometryReader + } +} + +extension MediaStackContainerView { + private func mediaView(viewModel: MediaView.ViewModel, at index: Int, dimension: CGFloat) -> some View { + GeometryReader { geo in + let transformAttribute = self.viewModel.transformAttribute(at: index) + ZStack { + MediaView(viewModel: viewModel) + .frame( + width: transformAttribute.transformFrame.width, + height: transformAttribute.transformFrame.height + ) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay(alignment: .bottom) { + MediaMetaIndicatorView(viewModel: viewModel) + } + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color(uiColor: .placeholderText).opacity(0.5), lineWidth: 1) + ) + .offset( + x: transformAttribute.offsetX, + y: transformAttribute.offsetY + ) + } + .frame(width: dimension, height: dimension) + .onTapGesture { + handler(viewModel, .preview) + } + } + .frame(width: dimension, height: dimension) + .zIndex(Double(999 - index)) + } // GeometryReader + +} + +extension MediaStackContainerView { + public class ViewModel: ObservableObject { + + // input + let items: [MediaView.ViewModel] + @Published var contentOffset: CGFloat = .zero + @Published var contentSize: CGSize = .zero + + // output + var progress: CGFloat { + return abs(contentOffset) / contentSize.width + } + + public init(items: [MediaView.ViewModel]) { + self.items = items + // end init + } + + } +} + +extension MediaStackContainerView.ViewModel { + func frame(at index: Int) -> CGRect { + let count = items.count + guard count > 0 else { return .zero } + let width = contentSize.width / CGFloat(count) + let minX = CGFloat(index) * width + let frame = CGRect( + x: minX, + y: 0, + width: width, + height: contentSize.height + ) + return frame + } + + func viewPortRect() -> CGRect { + let count = items.count + guard count > 0 else { return .zero } + let width = contentSize.width / CGFloat(count) + let rect = CGRect( + origin: .init(x: -contentOffset, y: 0), + size: .init(width: width, height: contentSize.height) + ) + return rect + } + + struct TransformAttribute { + let originalFrame: CGRect + let transformFrame: CGRect + let zIndex: Int + let alpha: CGFloat + + init( + originalFrame: CGRect, + transformFrame: CGRect, + zIndex: Int, + alpha: CGFloat + ) { + self.originalFrame = originalFrame + self.transformFrame = transformFrame + self.zIndex = zIndex + self.alpha = alpha + } + + var offsetX: CGFloat { + return (transformFrame.minX - originalFrame.minX) + (transformFrame.width - originalFrame.width) / 2 + //return transformFrame.origin.x - originalFrame.origin.x + } + + var offsetY: CGFloat { + return .zero // (transformFrame.height - originalFrame.height) / 2 + //return transformFrame.origin.y - originalFrame.origin.y + } + } + + var sizeScaleRatio: CGFloat { 0.8 } + var trailingMarginRatio: CGFloat { 0.1 } + + func transformAttribute(at index: Int) -> TransformAttribute { + let originalFrame = frame(at: index) + let viewPortRect = self.viewPortRect() + + // calculate constants + let endFrameSize = CGSize( + width: viewPortRect.width * (1 - trailingMarginRatio), + height: viewPortRect.height + ) + let startFrameSize = CGSize( + width: endFrameSize.width * sizeScaleRatio, + height: endFrameSize.height * sizeScaleRatio + ) + + if originalFrame.minX <= viewPortRect.minX { + // A: top most cover + // set frame + return TransformAttribute( + originalFrame: originalFrame, + transformFrame: CGRect( + x: originalFrame.origin.x, + y: originalFrame.origin.y, + width: endFrameSize.width, + height: endFrameSize.height + ), + zIndex: Int.max - index, + alpha: 1 + ) + } else if originalFrame.minX <= viewPortRect.maxX { + // B: middle cover + // timing curve + let offset = viewPortRect.maxX - originalFrame.minX + let t = offset / viewPortRect.width + let timingCurve = easeInOutInterpolation(progress: t) + // get current scale ratio + let scaleRatio: CGFloat = { + let start = sizeScaleRatio + let end: CGFloat = 1 + return lerp(v0: start, v1: end, t: timingCurve) + }() + // set height + let height = endFrameSize.height * scaleRatio + // pin offsetY + let topMargin = (viewPortRect.height - height) / 2 + // set width + let width = endFrameSize.width * scaleRatio + // set offsetX + let end = viewPortRect.origin.x + let start = viewPortRect.maxX - width + let minX = lerp(v0: start, v1: end, t: timingCurve) + // set alpha + let alpha = lerp(v0: 0.5, v1: 1, t: timingCurve) + return TransformAttribute( + originalFrame: originalFrame, + transformFrame: CGRect( + x: minX, + y: topMargin - (originalFrame.height - endFrameSize.height) / 2, + width: width, + height: height + ), + zIndex: Int.max - index, + alpha: alpha + ) + } else { + // C: bottom cover + // timing curve + let offset = originalFrame.minX - viewPortRect.maxX + let t = 1 - (offset / viewPortRect.width) + // set height + let height = startFrameSize.height + // pin offsetY + let topMargin = (viewPortRect.height - height) / 2 + // set width + let width = startFrameSize.width + // set offsetX + let minX = viewPortRect.maxX - width + // set alpha + let alpha = lerp(v0: 0, v1: 0.5, t: t) + return TransformAttribute( + originalFrame: originalFrame, + transformFrame: CGRect( + x: minX, + y: topMargin, + width: width, + height: height + ), + zIndex: Int.max - index, + alpha: alpha + ) + } + } +} + +// ref: +// - https://stackoverflow.com/questions/13462001/ease-in-and-ease-out-animation-formula +// - https://math.stackexchange.com/questions/121720/ease-in-out-function/121755#121755 +// for a = 2 +func easeInOutInterpolation(progress t: CGFloat) -> CGFloat { + let sqt = t * t + return sqt / (2.0 * (sqt - t) + 1.0) +} + +// linear interpolation +func lerp(v0: CGFloat, v1: CGFloat, t: CGFloat) -> CGFloat { + return (1 - t) * v0 + (t * v1) +} diff --git a/TwidereSDK/Sources/TwidereUI/Content/EmptyStateView.swift b/TwidereSDK/Sources/TwidereUI/Content/EmptyStateView.swift new file mode 100644 index 00000000..4c3b4015 --- /dev/null +++ b/TwidereSDK/Sources/TwidereUI/Content/EmptyStateView.swift @@ -0,0 +1,86 @@ +// +// EmptyStateView.swift +// +// +// Created by MainasuK on 2023-06-20. +// + +import UIKit +import SwiftUI +import TwidereCore + +public struct EmptyStateView: View { + @ObservedObject public var viewModel: ViewModel + + public init(viewModel: EmptyStateView.ViewModel) { + self.viewModel = viewModel + } + + public var body: some View { + VStack { + Spacer() + Spacer() + Spacer() + VStack { + if let iconSystemName = viewModel.iconSystemName { + Image(systemName: iconSystemName) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 120, height: 120) + .foregroundColor(.secondary) + .font(.title) + .opacity(0.5) + } + VStack(spacing: 8) { + if let title = viewModel.title { + Text(verbatim: title) + .foregroundColor(.secondary) + .font(.headline) + } + if let subtitle = viewModel.subtitle { + Text(verbatim: subtitle) + .foregroundColor(.secondary) + .font(.subheadline) + .frame(maxWidth: 300) + .multilineTextAlignment(.center) + } + } // end VStack + } // end VStack + Spacer() + Spacer() + Spacer() + Spacer() + } // end VStack + } +} + +extension EmptyStateView { + public class ViewModel: ObservableObject { + // input + @Published public var emptyState: EmptyState? + + // ouptut + var iconSystemName: String? { + emptyState?.iconSystemName + } + var title: String? { + emptyState?.title + } + var subtitle: String? { + emptyState?.subtitle + } + + public init(emptyState: EmptyState? = nil) { + self.emptyState = emptyState + // end init + } + } +} + +struct EmptyStateView_Previews: PreviewProvider { + static var previews: some View { + EmptyStateView(viewModel: .init(emptyState: .noResults)) + EmptyStateView(viewModel: .init(emptyState: .unableToAccess())) + + } +} diff --git a/TwidereSDK/Sources/TwidereUI/Content/MediaMetaIndicatorView.swift b/TwidereSDK/Sources/TwidereUI/Content/MediaMetaIndicatorView.swift new file mode 100644 index 00000000..d83c5aaf --- /dev/null +++ b/TwidereSDK/Sources/TwidereUI/Content/MediaMetaIndicatorView.swift @@ -0,0 +1,38 @@ +// +// MediaMetaIndicatorView.swift +// +// +// Created by MainasuK on 2023/4/19. +// + +import SwiftUI + +public struct MediaMetaIndicatorView: View { + + @ObservedObject public var viewModel: MediaView.ViewModel + + public init(viewModel: MediaView.ViewModel) { + self.viewModel = viewModel + } + + public var body: some View { + HStack { + Spacer() + Group { + if viewModel.mediaKind == .animatedGIF { + Text("GIF") + } else if let durationText = viewModel.durationText { + Text("\(Image(systemName: "play.fill")) \(durationText)") + } + } + .foregroundColor(Color(uiColor: .label)) + .font(.system(.footnote, design: .default, weight: .medium)) + .padding(.horizontal, 5) + .padding(.vertical, 3) + .background(.thinMaterial) + .cornerRadius(4) + } + .padding(EdgeInsets(top: 0, leading: 11, bottom: 8, trailing: 11)) + .allowsHitTesting(false) + } +} diff --git a/TwidereSDK/Sources/TwidereUI/Content/MediaView+Configuration.swift b/TwidereSDK/Sources/TwidereUI/Content/MediaView+Configuration.swift deleted file mode 100644 index 758a749c..00000000 --- a/TwidereSDK/Sources/TwidereUI/Content/MediaView+Configuration.swift +++ /dev/null @@ -1,177 +0,0 @@ -// -// MediaView+Configuration.swift -// TwidereX -// -// Created by Cirno MainasuK on 2021-10-14. -// Copyright © 2021 Twidere. All rights reserved. -// - -import UIKit -import Combine -import CoreData -import CoreDataStack -import Photos - -extension MediaView { - public enum Configuration: Hashable { - case image(info: ImageInfo) - case gif(info: VideoInfo) - case video(info: VideoInfo) - - public var aspectRadio: CGSize { - switch self { - case .image(let info): return info.aspectRadio - case .gif(let info): return info.aspectRadio - case .video(let info): return info.aspectRadio - } - } - - public var assetURL: String? { - switch self { - case .image(let info): - return info.assetURL - case .gif(let info): - return info.assetURL - case .video(let info): - return info.assetURL - } - } - - public var downloadURL: String? { - switch self { - case .image(let info): - return info.downloadURL ?? info.assetURL - case .gif(let info): - return info.assetURL - case .video(let info): - return info.assetURL - } - } - - public var resourceType: PHAssetResourceType { - switch self { - case .image: - return .photo - case .gif: - return .video - case .video: - return .video - } - } - - public struct ImageInfo: Hashable { - public let aspectRadio: CGSize - public let assetURL: String? - public let downloadURL: String? - - public init( - aspectRadio: CGSize, - assetURL: String?, - downloadURL: String? - ) { - self.aspectRadio = aspectRadio - self.assetURL = assetURL - self.downloadURL = downloadURL - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(aspectRadio.width) - hasher.combine(aspectRadio.height) - assetURL.flatMap { hasher.combine($0) } - } - } - - public struct VideoInfo: Hashable { - public let aspectRadio: CGSize - public let assetURL: String? - public let previewURL: String? - public let durationMS: Int? - - public init( - aspectRadio: CGSize, - assetURL: String?, - previewURL: String?, - durationMS: Int? - ) { - self.aspectRadio = aspectRadio - self.assetURL = assetURL - self.previewURL = previewURL - self.durationMS = durationMS - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(aspectRadio.width) - hasher.combine(aspectRadio.height) - assetURL.flatMap { hasher.combine($0) } - previewURL.flatMap { hasher.combine($0) } - durationMS.flatMap { hasher.combine($0) } - } - } - } -} - -extension MediaView { - public static func configuration(twitterStatus status: TwitterStatus) -> [MediaView.Configuration] { - func videoInfo(from attachment: TwitterAttachment) -> MediaView.Configuration.VideoInfo { - MediaView.Configuration.VideoInfo( - aspectRadio: attachment.size, - assetURL: attachment.assetURL, - previewURL: attachment.previewURL, - durationMS: attachment.durationMS - ) - } - - let status = status.repost ?? status - return status.attachments.map { attachment -> MediaView.Configuration in - switch attachment.kind { - case .photo: - let info = MediaView.Configuration.ImageInfo( - aspectRadio: attachment.size, - assetURL: attachment.assetURL, - downloadURL: attachment.downloadURL - ) - return .image(info: info) - case .video: - let info = videoInfo(from: attachment) - return .video(info: info) - case .animatedGIF: - let info = videoInfo(from: attachment) - return .gif(info: info) - } - } - } - - public static func configuration(mastodonStatus status: MastodonStatus) -> [MediaView.Configuration] { - func videoInfo(from attachment: MastodonAttachment) -> MediaView.Configuration.VideoInfo { - MediaView.Configuration.VideoInfo( - aspectRadio: attachment.size, - assetURL: attachment.assetURL, - previewURL: attachment.previewURL, - durationMS: attachment.durationMS - ) - } - - let status = status.repost ?? status - return status.attachments.map { attachment -> MediaView.Configuration in - switch attachment.kind { - case .image: - let info = MediaView.Configuration.ImageInfo( - aspectRadio: attachment.size, - assetURL: attachment.assetURL, - downloadURL: attachment.downloadURL - ) - return .image(info: info) - case .video: - let info = videoInfo(from: attachment) - return .video(info: info) - case .gifv: - let info = videoInfo(from: attachment) - return .gif(info: info) - case .audio: - // TODO: - let info = videoInfo(from: attachment) - return .video(info: info) - } - } - } -} diff --git a/TwidereSDK/Sources/TwidereUI/Content/MediaView+ViewModel.swift b/TwidereSDK/Sources/TwidereUI/Content/MediaView+ViewModel.swift new file mode 100644 index 00000000..be129b78 --- /dev/null +++ b/TwidereSDK/Sources/TwidereUI/Content/MediaView+ViewModel.swift @@ -0,0 +1,293 @@ +// +// MediaView+ViewModel.swift +// TwidereX +// +// Created by Cirno MainasuK on 2021-10-14. +// Copyright © 2021 Twidere. All rights reserved. +// + +import UIKit +import SwiftUI +import Combine +import CoreData +import CoreDataStack +import Photos + +extension MediaView { + public class ViewModel: ObservableObject, Hashable { + + public static let durationFormatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.zeroFormattingBehavior = .pad + formatter.allowedUnits = [.minute, .second] + return formatter + }() + + // input + public let mediaKind: MediaKind + public let aspectRatio: CGSize + public let altText: String? + + public let previewURL: URL? + public let assetURL: URL? + public let downloadURL: URL? + + // video duration in MS + public let durationMS: Int? + + @Published public var shouldHideForTransitioning = false + + // output + public var durationText: String? + public var thumbnail: UIImage? = nil + public var frameInWindow: CGRect = .zero + + public init( + mediaKind: MediaKind, + aspectRatio: CGSize, + altText: String?, + previewURL: URL?, + assetURL: URL?, + downloadURL: URL?, + durationMS: Int? + ) { + self.mediaKind = mediaKind + self.aspectRatio = aspectRatio + self.altText = altText + self.previewURL = previewURL + self.assetURL = assetURL + self.downloadURL = downloadURL + self.durationMS = durationMS + // end init + + self.durationText = durationMS.flatMap { durationMS -> String? in + let timeInterval = TimeInterval(durationMS / 1000) + guard timeInterval > 0 else { return nil } + guard let text = MediaView.ViewModel.durationFormatter.string(from: timeInterval) else { return nil } + return text + } + } + + public static func == (lhs: MediaView.ViewModel, rhs: MediaView.ViewModel) -> Bool { + return lhs.mediaKind == rhs.mediaKind + && lhs.aspectRatio == rhs.aspectRatio + && lhs.altText == rhs.altText + && lhs.previewURL == rhs.previewURL + && lhs.assetURL == rhs.assetURL + && lhs.downloadURL == rhs.downloadURL + && lhs.durationMS == rhs.durationMS + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(mediaKind) + hasher.combine(aspectRatio.width) + hasher.combine(aspectRatio.height) + hasher.combine(altText) + hasher.combine(previewURL) + hasher.combine(assetURL) + hasher.combine(downloadURL) + hasher.combine(durationMS) + } + + } +} + +extension MediaView.ViewModel { + public enum MediaKind { + case video + case photo + case animatedGIF + } + + public enum Action { + case preview + case previewWithContext(ContextMenuInteractionPreviewActionContext) + case save + case copy + case shareLink + case shareMedia + + public var title: String { + switch self { + case .preview: return "Preview" + case .previewWithContext: return "Preview with context" + case .save: return L10n.Common.Controls.Actions.save + case .copy: return L10n.Common.Controls.Actions.copy + case .shareLink: return L10n.Common.Controls.Actions.shareLink + case .shareMedia: return L10n.Common.Controls.Actions.shareMedia + } + } + + public var image: UIImage? { + switch self { + case .preview: return UIImage(systemName: "eye") + case .previewWithContext: return UIImage(systemName: "eye.fill") + case .save: return UIImage(systemName: "square.and.arrow.down") + case .copy: return UIImage(systemName: "doc.on.doc") + case .shareLink: return UIImage(systemName: "link") + case .shareMedia: return UIImage(systemName: "photo") + } + } + } +} + +extension MediaView.ViewModel.MediaKind { + public var resourceType: PHAssetResourceType { + switch self { + case .video: return .video + case .photo: return .photo + case .animatedGIF: return .video + } + } +} + + +//extension MediaView { +// public enum Configuration: Hashable { +// case image(info: ImageInfo) +// case gif(info: VideoInfo) +// case video(info: VideoInfo) +// +// public var aspectRadio: CGSize { +// switch self { +// case .image(let info): return info.aspectRadio +// case .gif(let info): return info.aspectRadio +// case .video(let info): return info.aspectRadio +// } +// } +// +// public var assetURL: String? { +// switch self { +// case .image(let info): +// return info.assetURL +// case .gif(let info): +// return info.assetURL +// case .video(let info): +// return info.assetURL +// } +// } +// +// public var downloadURL: String? { +// switch self { +// case .image(let info): +// return info.downloadURL ?? info.assetURL +// case .gif(let info): +// return info.assetURL +// case .video(let info): +// return info.assetURL +// } +// } +// +// public var resourceType: PHAssetResourceType { +// switch self { +// case .image: +// return .photo +// case .gif: +// return .video +// case .video: +// return .video +// } +// } +// +// public struct ImageInfo: Hashable { +// public let aspectRadio: CGSize +// public let assetURL: String? +// public let downloadURL: String? +// +// public init( +// aspectRadio: CGSize, +// assetURL: String?, +// downloadURL: String? +// ) { +// self.aspectRadio = aspectRadio +// self.assetURL = assetURL +// self.downloadURL = downloadURL +// } +// +// public func hash(into hasher: inout Hasher) { +// hasher.combine(aspectRadio.width) +// hasher.combine(aspectRadio.height) +// assetURL.flatMap { hasher.combine($0) } +// } +// } +// +// public struct VideoInfo: Hashable { +// public let aspectRadio: CGSize +// public let assetURL: String? +// public let previewURL: String? +// public let durationMS: Int? +// +// public init( +// aspectRadio: CGSize, +// assetURL: String?, +// previewURL: String?, +// durationMS: Int? +// ) { +// self.aspectRadio = aspectRadio +// self.assetURL = assetURL +// self.previewURL = previewURL +// self.durationMS = durationMS +// } +// +// public func hash(into hasher: inout Hasher) { +// hasher.combine(aspectRadio.width) +// hasher.combine(aspectRadio.height) +// assetURL.flatMap { hasher.combine($0) } +// previewURL.flatMap { hasher.combine($0) } +// durationMS.flatMap { hasher.combine($0) } +// } +// } +// } +//} +// +extension MediaView.ViewModel { + public static func viewModels(from status: StatusObject) -> [MediaView.ViewModel] { + switch status { + case .twitter(let object): + return viewModels(from: object) + case .mastodon(let object): + return viewModels(from: object) + } + } + + public static func viewModels(from status: TwitterStatus) -> [MediaView.ViewModel] { + return status.attachmentsTransient.map { attachment -> MediaView.ViewModel in + MediaView.ViewModel( + mediaKind: { + switch attachment.kind { + case .photo: return .photo + case .video: return .video + case .animatedGIF: return .animatedGIF + } + }(), + aspectRatio: attachment.size, + altText: attachment.altDescription, + previewURL: (attachment.previewURL ?? attachment.assetURL).flatMap { URL(string: $0) }, + assetURL: attachment.assetURL.flatMap { URL(string: $0) }, + downloadURL: attachment.downloadURL.flatMap { URL(string: $0) }, + durationMS: attachment.durationMS + ) + } + } + + public static func viewModels(from status: MastodonStatus) -> [MediaView.ViewModel] { + return status.attachmentsTransient.map { attachment -> MediaView.ViewModel in + MediaView.ViewModel( + mediaKind: { + switch attachment.kind { + case .image: return .photo + case .video: return .video + case .audio: return .video + case .gifv: return .animatedGIF + } + }(), + aspectRatio: attachment.size, + altText: attachment.altDescription, + previewURL: (attachment.previewURL ?? attachment.assetURL).flatMap { URL(string: $0) }, + assetURL: attachment.assetURL.flatMap { URL(string: $0) }, + downloadURL: attachment.downloadURL.flatMap { URL(string: $0) }, + durationMS: attachment.durationMS + ) + } + } +} diff --git a/TwidereSDK/Sources/TwidereUI/Content/MediaView.swift b/TwidereSDK/Sources/TwidereUI/Content/MediaView.swift index f114de3e..2f531a1d 100644 --- a/TwidereSDK/Sources/TwidereUI/Content/MediaView.swift +++ b/TwidereSDK/Sources/TwidereUI/Content/MediaView.swift @@ -8,323 +8,90 @@ import AVKit import UIKit +import SwiftUI import Combine import TwidereAsset +import Kingfisher -public final class MediaView: UIView { +public struct MediaView: View { - var disposeBag = Set() + @ObservedObject var viewModel: ViewModel - public static let cornerRadius: CGFloat = 8 - public static let durationFormatter: DateComponentsFormatter = { - let formatter = DateComponentsFormatter() - formatter.zeroFormattingBehavior = .pad - formatter.allowedUnits = [.minute, .second] - return formatter - }() - public static let borderColor: UIColor = UIColor.label.withAlphaComponent(0.05) - public static let borderWidth: CGFloat = 1 - - public let container = TouchBlockingView() - - public private(set) var configuration: Configuration? - - private(set) lazy var imageView: UIImageView = { - let imageView = UIImageView() - imageView.contentMode = .scaleAspectFill - imageView.layer.masksToBounds = true - imageView.layer.cornerCurve = .continuous - imageView.layer.cornerRadius = MediaView.cornerRadius - imageView.layer.borderColor = MediaView.borderColor.cgColor - imageView.layer.borderWidth = MediaView.borderWidth - imageView.isUserInteractionEnabled = false - return imageView - }() - - private(set) lazy var playerViewController: AVPlayerViewController = { - let playerViewController = AVPlayerViewController() - playerViewController.view.layer.masksToBounds = true - playerViewController.view.layer.cornerCurve = .continuous - playerViewController.view.layer.cornerRadius = MediaView.cornerRadius - playerViewController.view.layer.borderColor = MediaView.borderColor.cgColor - playerViewController.view.layer.borderWidth = MediaView.borderWidth - playerViewController.view.isUserInteractionEnabled = false - return playerViewController - }() - private var playerLooper: AVPlayerLooper? - - private(set) lazy var indicatorBlurEffectView: UIVisualEffectView = { - let effectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterial)) - effectView.layer.masksToBounds = true - effectView.layer.cornerCurve = .continuous - effectView.layer.cornerRadius = 4 - return effectView - }() - private(set) lazy var indicatorVibrancyEffectView = UIVisualEffectView( - effect: UIVibrancyEffect(blurEffect: UIBlurEffect(style: .systemUltraThinMaterial)) - ) - private(set) lazy var playerIndicatorLabel: UILabel = { - let label = UILabel() - label.font = .preferredFont(forTextStyle: .caption1) - label.textColor = .secondaryLabel - return label - }() - - public override init(frame: CGRect) { - super.init(frame: frame) - _init() - } - - public required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } - -} - -extension MediaView { - - @MainActor - public func thumbnail() async -> UIImage? { - return imageView.image - } - - public func thumbnail() -> UIImage? { - return imageView.image - } - -} - -extension MediaView { - private func _init() { - // lazy load content later - - imageView.isAccessibilityElement = true - } - - public func setup(configuration: Configuration) { - self.configuration = configuration - - setupContainerViewHierarchy() - - switch configuration { - case .image(let info): - configure(image: info, containerView: container) - case .gif(let info): - configure(gif: info) - case .video(let info): - configure(video: info) - } + public init(viewModel: MediaView.ViewModel) { + self.viewModel = viewModel } - private func configure( - image info: Configuration.ImageInfo, - containerView: UIView - ) { - imageView.translatesAutoresizingMaskIntoConstraints = false - containerView.addSubview(imageView) - NSLayoutConstraint.activate([ - imageView.topAnchor.constraint(equalTo: containerView.topAnchor), - imageView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), - imageView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), - imageView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), - ]) - - let placeholder = Asset.Logo.mediaPlaceholder.image - imageView.contentMode = .center - imageView.backgroundColor = .systemGray6 - - guard let urlString = info.assetURL, - let url = URL(string: urlString) else { - imageView.image = placeholder - return - } - - imageView.af.setImage( - withURL: url, - placeholderImage: placeholder, - completion: { [weak imageView] response in - assert(Thread.isMainThread) - switch response.result { - case .success: - imageView?.contentMode = .scaleAspectFill - case .failure: - break + public var body: some View { + KFImage(viewModel.previewURL) + .onSuccess { result in + viewModel.thumbnail = result.image + } + .cancelOnDisappear(true) + .resizable() + .placeholder { progress in + Image(uiImage: Asset.Logo.mediaPlaceholder.image.withRenderingMode(.alwaysTemplate)) + } + .aspectRatio(contentMode: .fill) + .overlay { + switch viewModel.mediaKind { + case .animatedGIF: + if let assetURL = viewModel.downloadURL { + GIFVideoPlayerRepresentable(assetURL: assetURL) + } else { + EmptyView() + } + default: + EmptyView() } - }) - } - - private func configure(gif info: Configuration.VideoInfo) { - // use view controller as View here - playerViewController.view.translatesAutoresizingMaskIntoConstraints = false - container.addSubview(playerViewController.view) - NSLayoutConstraint.activate([ - playerViewController.view.topAnchor.constraint(equalTo: container.topAnchor), - playerViewController.view.leadingAnchor.constraint(equalTo: container.leadingAnchor), - playerViewController.view.trailingAnchor.constraint(equalTo: container.trailingAnchor), - playerViewController.view.bottomAnchor.constraint(equalTo: container.bottomAnchor), - ]) - - assert(playerViewController.contentOverlayView != nil) - if let contentOverlayView = playerViewController.contentOverlayView { - let imageInfo = Configuration.ImageInfo( - aspectRadio: info.aspectRadio, - assetURL: info.previewURL, - downloadURL: info.previewURL - ) - configure(image: imageInfo, containerView: contentOverlayView) - - indicatorBlurEffectView.translatesAutoresizingMaskIntoConstraints = false - contentOverlayView.addSubview(indicatorBlurEffectView) - NSLayoutConstraint.activate([ - contentOverlayView.trailingAnchor.constraint(equalTo: indicatorBlurEffectView.trailingAnchor, constant: 11), - contentOverlayView.bottomAnchor.constraint(equalTo: indicatorBlurEffectView.bottomAnchor, constant: 8), - ]) - setupIndicatorViewHierarchy() - } - playerIndicatorLabel.attributedText = NSAttributedString(AttributedString("GIF")) - - guard let player = setupGIFPlayer(info: info) else { - // assertionFailure() - return - } - setupPlayerLooper(player: player) - playerViewController.player = player - playerViewController.showsPlaybackControls = false - - playerViewController.publisher(for: \.isReadyForDisplay) - .receive(on: DispatchQueue.main) - .sink { [weak self] isReadyForDisplay in - guard let self = self else { return } - self.imageView.isHidden = isReadyForDisplay } - .store(in: &disposeBag) - - // auto play for GIF - player.play() + .opacity(viewModel.shouldHideForTransitioning ? 0 : 1) } - private func configure(video info: Configuration.VideoInfo) { - let imageInfo = Configuration.ImageInfo( - aspectRadio: info.aspectRadio, - assetURL: info.previewURL, - downloadURL: info.previewURL - ) - configure(image: imageInfo, containerView: container) - - indicatorBlurEffectView.translatesAutoresizingMaskIntoConstraints = false - imageView.addSubview(indicatorBlurEffectView) - NSLayoutConstraint.activate([ - imageView.trailingAnchor.constraint(equalTo: indicatorBlurEffectView.trailingAnchor, constant: 11), - imageView.bottomAnchor.constraint(equalTo: indicatorBlurEffectView.bottomAnchor, constant: 8), - ]) - setupIndicatorViewHierarchy() - - playerIndicatorLabel.attributedText = { - let imageAttachment = NSTextAttachment(image: UIImage(systemName: "play.fill")!) - let imageAttributedString = AttributedString(NSAttributedString(attachment: imageAttachment)) - let duration: String = { - guard let durationMS = info.durationMS else { return "" } - let timeInterval = TimeInterval(durationMS / 1000) - guard timeInterval > 0 else { return "" } - guard let text = MediaView.durationFormatter.string(from: timeInterval) else { return "" } - return " \(text)" - }() - let textAttributedString = AttributedString("\(duration)") - var attributedString = imageAttributedString + textAttributedString - attributedString.foregroundColor = .secondaryLabel - return NSAttributedString(attributedString) - }() - - } - - public func prepareForReuse() { - // reset appearance - alpha = 1 - - // reset image - imageView.removeFromSuperview() - imageView.removeConstraints(imageView.constraints) - imageView.af.cancelImageRequest() - imageView.image = nil - imageView.isHidden = false - - // reset player - playerViewController.view.removeFromSuperview() - playerViewController.contentOverlayView.flatMap { view in - view.removeConstraints(view.constraints) - } - playerViewController.player?.pause() - playerViewController.player = nil - playerLooper = nil - - // reset indicator - indicatorBlurEffectView.removeFromSuperview() - - // reset container - container.removeFromSuperview() - container.removeConstraints(container.constraints) - - // reset configuration - configuration = nil - - disposeBag.removeAll() - } } -extension MediaView { - private func setupGIFPlayer(info: Configuration.VideoInfo) -> AVPlayer? { - guard let urlString = info.assetURL, - let url = URL(string: urlString) - else { return nil } - let playerItem = AVPlayerItem(url: url) - let player = AVQueuePlayer(playerItem: playerItem) - player.isMuted = true - return player - } - - private func setupPlayerLooper(player: AVPlayer) { - guard let queuePlayer = player as? AVQueuePlayer else { return } - guard let templateItem = queuePlayer.items().first else { return } - playerLooper = AVPlayerLooper(player: queuePlayer, templateItem: templateItem) - } +struct MediaView_Previews: PreviewProvider { + + static let viewModels = { + let models = [ + MediaView.ViewModel( + mediaKind: .photo, + aspectRatio: CGSize(width: 2048, height: 1186), + altText: nil, + previewURL: URL(string: "https://www.nasa.gov/sites/default/files/thumbnails/image/web_first_images_release.png"), + assetURL: URL(string: "https://www.nasa.gov/sites/default/files/thumbnails/image/web_first_images_release.png"), + downloadURL: URL(string: "https://www.nasa.gov/sites/default/files/thumbnails/image/web_first_images_release.png"), + durationMS: nil + ), + MediaView.ViewModel( + mediaKind: .video, + aspectRatio: CGSize(width: 1200, height: 675), + altText: nil, + previewURL: URL(string: "https://pbs.twimg.com/ext_tw_video_thumb/1630058212258115584/pu/img/slS0fYBeGKp8LXzC.jpg"), + assetURL: URL(string: "https://pbs.twimg.com/ext_tw_video_thumb/1630058212258115584/pu/img/slS0fYBeGKp8LXzC.jpg"), + downloadURL: URL(string: "https://video.twimg.com/ext_tw_video/1630058212258115584/pu/vid/1280x720/V-Jq9fMqwxTbZdxD.mp4?tag=12"), + durationMS: 27375 + ), + MediaView.ViewModel( + mediaKind: .animatedGIF, + aspectRatio: CGSize(width: 1200, height: 720), + altText: nil, + previewURL: URL(string: "https://media.mstdn.jp/cache/media_attachments/files/109/936/306/341/672/302/small/19d1c52c1a1713b2.png"), + assetURL: URL(string: "https://media.mstdn.jp/cache/media_attachments/files/109/936/306/341/672/302/small/19d1c52c1a1713b2.png"), + downloadURL: URL(string: "https://media.mstdn.jp/cache/media_attachments/files/109/936/306/341/672/302/original/19d1c52c1a1713b2.mp4"), + durationMS: 11084 + ), + ] + return models + }() - private func setupContainerViewHierarchy() { - guard container.superview == nil else { return } - container.translatesAutoresizingMaskIntoConstraints = false - addSubview(container) - NSLayoutConstraint.activate([ - container.topAnchor.constraint(equalTo: topAnchor), - container.leadingAnchor.constraint(equalTo: leadingAnchor), - container.trailingAnchor.constraint(equalTo: trailingAnchor), - container.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - } - - private func setupIndicatorViewHierarchy() { - let blurEffectView = indicatorBlurEffectView - let vibrancyEffectView = indicatorVibrancyEffectView - - if vibrancyEffectView.superview == nil { - vibrancyEffectView.translatesAutoresizingMaskIntoConstraints = false - blurEffectView.contentView.addSubview(vibrancyEffectView) - NSLayoutConstraint.activate([ - vibrancyEffectView.topAnchor.constraint(equalTo: blurEffectView.contentView.topAnchor), - vibrancyEffectView.leadingAnchor.constraint(equalTo: blurEffectView.contentView.leadingAnchor), - vibrancyEffectView.trailingAnchor.constraint(equalTo: blurEffectView.contentView.trailingAnchor), - vibrancyEffectView.bottomAnchor.constraint(equalTo: blurEffectView.contentView.bottomAnchor), - ]) - } - - if playerIndicatorLabel.superview == nil { - playerIndicatorLabel.translatesAutoresizingMaskIntoConstraints = false - vibrancyEffectView.contentView.addSubview(playerIndicatorLabel) - NSLayoutConstraint.activate([ - playerIndicatorLabel.topAnchor.constraint(equalTo: vibrancyEffectView.contentView.topAnchor), - playerIndicatorLabel.leadingAnchor.constraint(equalTo: vibrancyEffectView.contentView.leadingAnchor, constant: 3), - vibrancyEffectView.contentView.trailingAnchor.constraint(equalTo: playerIndicatorLabel.trailingAnchor, constant: 3), - playerIndicatorLabel.bottomAnchor.constraint(equalTo: vibrancyEffectView.contentView.bottomAnchor), - ]) + static var previews: some View { + Group { + ForEach(viewModels, id: \.self) { viewModel in + MediaView(viewModel: viewModel) + .frame(width: 300, height: 168) + .previewLayout(.fixed(width: 300, height: 168)) + .previewDisplayName(String(describing: viewModel.mediaKind)) + } } } } diff --git a/TwidereSDK/Sources/TwidereUI/Content/NotificationView+ViewModel.swift b/TwidereSDK/Sources/TwidereUI/Content/NotificationView+ViewModel.swift new file mode 100644 index 00000000..9c4ed017 --- /dev/null +++ b/TwidereSDK/Sources/TwidereUI/Content/NotificationView+ViewModel.swift @@ -0,0 +1,140 @@ +// +// NotificationView+ViewModel.swift +// +// +// Created by MainasuK on 2023/4/11. +// + +import os.log +import SwiftUI +import Combine +import CoreDataStack + +extension NotificationView { + public class ViewModel: ObservableObject { + + let logger = Logger(subsystem: "StatusView", category: "ViewModel") + + @Published public var viewLayoutFrame = ViewLayoutFrame() + + + // input + public let notification: NotificationObject + public let authContext: AuthContext? + + // output + + // user + @Published public var userViewModel: UserView.ViewModel? + + // status + @Published public var statusViewModel: StatusView.ViewModel? + + // header + @Published var notificationHeaderViewModel: StatusHeaderView.ViewModel? + + public init( + notification: NotificationObject, + authContext: AuthContext?, + viewLayoutFramePublisher: Published.Publisher?, + statusViewModel: StatusView.ViewModel?, + userViewModel: UserView.ViewModel? + ) { + self.notification = notification + self.authContext = authContext + self.statusViewModel = statusViewModel + self.userViewModel = userViewModel + // end init + + viewLayoutFramePublisher?.assign(to: &$viewLayoutFrame) + } + + } +} + +extension NotificationView.ViewModel { + public convenience init( + notification: NotificationObject, + authContext: AuthContext?, + statusViewDelegate: StatusViewDelegate?, + userViewDelegate: UserViewDelegate?, + viewLayoutFramePublisher: Published.Publisher? + ) { + switch notification { + case .twitter(let status): + self.init( + notification: notification, + authContext: authContext, + viewLayoutFramePublisher: viewLayoutFramePublisher, + statusViewModel: .init( + status: status, + authContext: authContext, + kind: .timeline, + delegate: statusViewDelegate, + parentViewModel: nil, + viewLayoutFramePublisher: viewLayoutFramePublisher + ), + userViewModel: nil + ) + case .mastodon(let notification): + self.init( + notification: notification, + authContext: authContext, + statusViewDelegate: statusViewDelegate, + userViewDelegate: userViewDelegate, + viewLayoutFramePublisher: viewLayoutFramePublisher + ) + } + } // end init +} + +extension NotificationView.ViewModel { + public convenience init( + notification: MastodonNotification, + authContext: AuthContext?, + statusViewDelegate: StatusViewDelegate?, + userViewDelegate: UserViewDelegate?, + viewLayoutFramePublisher: Published.Publisher? + ) { + self.init( + notification: .mastodon(object: notification), + authContext: authContext, + viewLayoutFramePublisher: viewLayoutFramePublisher, + statusViewModel: { + guard let status = notification.status else { return nil } + return StatusView.ViewModel( + status: status, + authContext: authContext, + kind: .timeline, + delegate: statusViewDelegate, + parentViewModel: nil, + viewLayoutFramePublisher: viewLayoutFramePublisher + ) + }(), + userViewModel: { + guard notification.status == nil else { return nil } + let ViewModel = UserView.ViewModel( + user: notification.account, + authContext: authContext, + kind: .notification(.mastodon(object: notification)), + delegate: userViewDelegate + ) + return ViewModel + }() + ) + + // header + let _info = NotificationHeaderInfo( + type: notification.notificationType, + user: notification.account + ) + if let info = _info { + let _notificationHeaderViewModel = StatusHeaderView.ViewModel( + image: info.iconImage, + label: info.textMetaContent + ) + _notificationHeaderViewModel.hasHangingAvatar = true + self.notificationHeaderViewModel = _notificationHeaderViewModel + } + } // end init +} diff --git a/TwidereSDK/Sources/TwidereUI/Content/NotificationView.swift b/TwidereSDK/Sources/TwidereUI/Content/NotificationView.swift new file mode 100644 index 00000000..3e7af058 --- /dev/null +++ b/TwidereSDK/Sources/TwidereUI/Content/NotificationView.swift @@ -0,0 +1,53 @@ +// +// NotificationView.swift +// +// +// Created by MainasuK on 2023/4/11. +// + +import SwiftUI + +public struct NotificationView: View { + + static var verticalMargin: CGFloat = 8 + + @ObservedObject public private(set) var viewModel: ViewModel + + @Environment(\.dynamicTypeSize) var dynamicTypeSize + + public init(viewModel: NotificationView.ViewModel) { + self.viewModel = viewModel + } + + public var body: some View { + VStack(spacing: .zero) { + // header + if let notificationHeaderViewModel = viewModel.notificationHeaderViewModel { + StatusHeaderView(viewModel: notificationHeaderViewModel) + .padding(.top, NotificationView.verticalMargin) + .allowsHitTesting(false) + } + // status + if let statusViewModel = viewModel.statusViewModel { + StatusView(viewModel: statusViewModel) + } else { + if let userViewModel = viewModel.userViewModel { + UserView(viewModel: userViewModel) + } + Color.clear + .frame(height: NotificationView.verticalMargin) + .overlay { + HStack(spacing: StatusView.hangingAvatarButtonTrailingSpacing) { + Color.clear.frame(width: StatusView.hangingAvatarButtonDimension) + VStack(spacing: .zero) { + Spacer() + Divider() + Color.clear.frame(height: 1) + } + } + } // end overlay + } // end if … else + } + } + +} diff --git a/TwidereSDK/Sources/TwidereUI/Content/PollOptionView+Configuration.swift b/TwidereSDK/Sources/TwidereUI/Content/PollOptionView+Configuration.swift index a55fe24c..dd8a3b93 100644 --- a/TwidereSDK/Sources/TwidereUI/Content/PollOptionView+Configuration.swift +++ b/TwidereSDK/Sources/TwidereUI/Content/PollOptionView+Configuration.swift @@ -14,165 +14,161 @@ import TwitterMeta import MastodonMeta import TwidereCore -extension PollOptionView { - public typealias ConfigurationContext = StatusView.ConfigurationContext -} +//extension PollOptionView { +// public typealias ConfigurationContext = StatusView.ConfigurationContext +//} -extension PollOptionView { - - public func configure( - pollOption: PollOptionObject, - configurationContext: ConfigurationContext - ) { - configurationContext.authenticationContext - .assign(to: \.authenticationContext, on: viewModel) - .store(in: &disposeBag) - - switch pollOption { - case .twitter(let object): - configure( - pollOption: object, - configurationContext: configurationContext - ) - case .mastodon(let object): - configure( - pollOption: object, - configurationContext: configurationContext - ) - } - } - - public func configure( - pollOption option: TwitterPollOption, - configurationContext: ConfigurationContext - ) { - viewModel.objects.insert(option) - - // metaContent - viewModel.metaContent = PlaintextMetaContent(string: option.label) - - // $isExpire - viewModel.isExpire = true // cannot vote for Twitter - - // isMultiple - viewModel.isMultiple = false - - // isSelect, isPollVoted, isMyPoll - viewModel.isSelect = false - viewModel.isPollVoted = false - viewModel.isMyPoll = false - - // percentage - Publishers.CombineLatest( - option.poll.publisher(for: \.updatedAt), - option.publisher(for: \.votes) - ) - .map { _, optionVotesCount -> Double? in - let pollVotesCount: Int = option.poll.options.map({ Int($0.votes) }).reduce(0, +) - guard pollVotesCount > 0, optionVotesCount >= 0 else { return 0 } - return Double(optionVotesCount) / Double(pollVotesCount) - } - .assign(to: \.percentage, on: viewModel) - .store(in: &disposeBag) - } - - public func configure( - pollOption option: MastodonPollOption, - configurationContext: ConfigurationContext - ) { - viewModel.objects.insert(option) - - // metaContent - Publishers.CombineLatest( - option.poll.status.publisher(for: \.emojis), - option.publisher(for: \.title) - ) - .map { emojis, title -> MetaContent? in - do { - let content = MastodonContent(content: title, emojis: emojis.asDictionary) - let metaContent = try MastodonMetaContent.convert(document: content) - return metaContent - } catch { - assertionFailure() - return PlaintextMetaContent(string: title) - } - } - .assign(to: \.metaContent, on: viewModel) - .store(in: &disposeBag) - - // $isExpire - option.poll.publisher(for: \.expired) - .assign(to: \.isExpire, on: viewModel) - .store(in: &disposeBag) - // isMultiple - viewModel.isMultiple = option.poll.multiple - - let optionIndex = option.index - let authorDomain = option.poll.status.author.domain - let authorUserID = option.poll.status.author.id - // isSelect, isPollVoted, isMyPoll - Publishers.CombineLatest4( - option.publisher(for: \.poll), - option.publisher(for: \.voteBy), - option.publisher(for: \.isSelected), - viewModel.$authenticationContext - ) - .sink { [weak self] poll, optionVoteBy, isSelected, authenticationContext in - guard let self = self else { return } - - let domain: String - let userID: String - switch authenticationContext { - case .twitter, .none: - domain = "" - userID = "" - case .mastodon(let authenticationContext): - domain = authenticationContext.domain - userID = authenticationContext.userID - } - - let options = poll.options - let pollVoteBy = poll.voteBy - - let isMyPoll = authorDomain == domain - && authorUserID == userID - - let votedOptions = options.filter { option in - option.voteBy.contains(where: { $0.id == userID && $0.domain == domain }) - } - let isRemoteVotedOption = votedOptions.contains(where: { $0.index == optionIndex }) - let isRemoteVotedPoll = pollVoteBy.contains(where: { $0.id == userID && $0.domain == domain }) - - let isLocalVotedOption = isSelected - - let isSelect: Bool? = { - if isLocalVotedOption { - return true - } else if !votedOptions.isEmpty { - return isRemoteVotedOption ? true : false - } else if isRemoteVotedPoll, votedOptions.isEmpty { - // the poll voted. But server not mark voted options - return nil - } else { - return false - } - }() - self.viewModel.isSelect = isSelect - self.viewModel.isPollVoted = isRemoteVotedPoll - self.viewModel.isMyPoll = isMyPoll - } - .store(in: &disposeBag) - // percentage - Publishers.CombineLatest( - option.poll.publisher(for: \.votesCount), - option.publisher(for: \.votesCount) - ) - .map { pollVotesCount, optionVotesCount -> Double? in - guard pollVotesCount > 0, optionVotesCount >= 0 else { return 0 } - return Double(optionVotesCount) / Double(pollVotesCount) - } - .assign(to: \.percentage, on: viewModel) - .store(in: &disposeBag) - } +//extension PollOptionView { +// +// public func configure( +// pollOption: PollOptionObject +// configurationContext: ConfigurationContext +// ) { +// switch pollOption { +// case .twitter(let object): +// configure( +// pollOption: object, +// configurationContext: configurationContext +// ) +// case .mastodon(let object): +// configure( +// pollOption: object, +// configurationContext: configurationContext +// ) +// } +// } +// +// public func configure( +// pollOption option: TwitterPollOption, +// configurationContext: ConfigurationContext +// ) { +// viewModel.objects.insert(option) +// +// // metaContent +// viewModel.metaContent = PlaintextMetaContent(string: option.label) +// +// // $isExpire +// viewModel.isExpire = true // cannot vote for Twitter +// +// // isMultiple +// viewModel.isMultiple = false +// +// // isSelect, isPollVoted, isMyPoll +// viewModel.isSelect = false +// viewModel.isPollVoted = false +// viewModel.isMyPoll = false +// +// // percentage +// Publishers.CombineLatest( +// option.poll.publisher(for: \.updatedAt), +// option.publisher(for: \.votes) +// ) +// .map { _, optionVotesCount -> Double? in +// let pollVotesCount: Int = option.poll.options.map({ Int($0.votes) }).reduce(0, +) +// guard pollVotesCount > 0, optionVotesCount >= 0 else { return 0 } +// return Double(optionVotesCount) / Double(pollVotesCount) +// } +// .assign(to: \.percentage, on: viewModel) +// .store(in: &disposeBag) +// } -} +// public func configure( +// pollOption option: MastodonPollOption, +// configurationContext: ConfigurationContext +// ) { +// viewModel.objects.insert(option) +// +// // metaContent +// Publishers.CombineLatest( +// option.poll.status.publisher(for: \.emojis), +// option.publisher(for: \.title) +// ) +// .map { emojis, title -> MetaContent? in +// do { +// let content = MastodonContent(content: title, emojis: emojis.asDictionary) +// let metaContent = try MastodonMetaContent.convert(document: content) +// return metaContent +// } catch { +// assertionFailure() +// return PlaintextMetaContent(string: title) +// } +// } +// .assign(to: \.metaContent, on: viewModel) +// .store(in: &disposeBag) +// +// // $isExpire +// option.poll.publisher(for: \.expired) +// .assign(to: \.isExpire, on: viewModel) +// .store(in: &disposeBag) +// // isMultiple +// viewModel.isMultiple = option.poll.multiple +// +// let optionIndex = option.index +// let authorDomain = option.poll.status.author.domain +// let authorUserID = option.poll.status.author.id +// // isSelect, isPollVoted, isMyPoll +// Publishers.CombineLatest4( +// option.publisher(for: \.poll), +// option.publisher(for: \.voteBy), +// option.publisher(for: \.isSelected), +// viewModel.$authenticationContext +// ) +// .sink { [weak self] poll, optionVoteBy, isSelected, authenticationContext in +// guard let self = self else { return } +// +// let domain: String +// let userID: String +// switch authenticationContext { +// case .twitter, .none: +// domain = "" +// userID = "" +// case .mastodon(let authenticationContext): +// domain = authenticationContext.domain +// userID = authenticationContext.userID +// } +// +// let options = poll.options +// let pollVoteBy = poll.voteBy +// +// let isMyPoll = authorDomain == domain +// && authorUserID == userID +// +// let votedOptions = options.filter { option in +// option.voteBy.contains(where: { $0.id == userID && $0.domain == domain }) +// } +// let isRemoteVotedOption = votedOptions.contains(where: { $0.index == optionIndex }) +// let isRemoteVotedPoll = pollVoteBy.contains(where: { $0.id == userID && $0.domain == domain }) +// +// let isLocalVotedOption = isSelected +// +// let isSelect: Bool? = { +// if isLocalVotedOption { +// return true +// } else if !votedOptions.isEmpty { +// return isRemoteVotedOption ? true : false +// } else if isRemoteVotedPoll, votedOptions.isEmpty { +// // the poll voted. But server not mark voted options +// return nil +// } else { +// return false +// } +// }() +// self.viewModel.isSelect = isSelect +// self.viewModel.isPollVoted = isRemoteVotedPoll +// self.viewModel.isMyPoll = isMyPoll +// } +// .store(in: &disposeBag) +// // percentage +// Publishers.CombineLatest( +// option.poll.publisher(for: \.votesCount), +// option.publisher(for: \.votesCount) +// ) +// .map { pollVotesCount, optionVotesCount -> Double? in +// guard pollVotesCount > 0, optionVotesCount >= 0 else { return 0 } +// return Double(optionVotesCount) / Double(pollVotesCount) +// } +// .assign(to: \.percentage, on: viewModel) +// .store(in: &disposeBag) +// } +// +//} diff --git a/TwidereSDK/Sources/TwidereUI/Content/PollOptionView+ViewModel.swift b/TwidereSDK/Sources/TwidereUI/Content/PollOptionView+ViewModel.swift index 096b0497..d6b38524 100644 --- a/TwidereSDK/Sources/TwidereUI/Content/PollOptionView+ViewModel.swift +++ b/TwidereSDK/Sources/TwidereUI/Content/PollOptionView+ViewModel.swift @@ -14,242 +14,242 @@ import TwitterMeta import MastodonMeta import TwidereCore -extension PollOptionView { - - static let percentageFormatter: NumberFormatter = { - let formatter = NumberFormatter() - formatter.numberStyle = .percent - formatter.maximumFractionDigits = 1 - formatter.minimumIntegerDigits = 1 - formatter.roundingMode = .down - return formatter - }() - - public final class ViewModel: ObservableObject { - var disposeBag = Set() - var observations = Set() - var objects = Set() - - @Published var authenticationContext: AuthenticationContext? - - @Published var style: PollOptionView.Style? - - @Published public var content: String = "" // for edit style - - @Published public var metaContent: MetaContent? // for plain style - @Published public var percentage: Double? - - @Published public var isExpire: Bool = false - @Published public var isMultiple: Bool = false - @Published public var isSelect: Bool? = false // nil for server not return selection array - @Published public var isPollVoted: Bool = false - @Published public var isMyPoll: Bool = false - - // output - @Published public var corner: Corner = .none - @Published public var stripProgressTinitColor: UIColor = .clear - @Published public var selectImageTintColor: UIColor = Asset.Colors.hightLight.color - @Published public var isReveal: Bool = false - - @Published public var groupedAccessibilityLabel = "" - - init() { - // corner - $isMultiple - .map { $0 ? .radius(8) : .circle } - .assign(to: &$corner) - // stripProgressTinitColor - Publishers.CombineLatest3( - $style, - $isSelect, - $isReveal - ) - .map { style, isSelect, isReveal -> UIColor in - guard case .plain = style else { return .clear } - guard isReveal else { - return .clear - } - - if isSelect == true { - return Asset.Colors.hightLight.color.withAlphaComponent(0.75) - } else { - return Asset.Colors.hightLight.color.withAlphaComponent(0.20) - } - } - .assign(to: &$stripProgressTinitColor) - // selectImageTintColor - Publishers.CombineLatest( - $isSelect, - $isReveal - ) - .map { isSelect, isReveal in - guard let isSelect = isSelect else { - return .clear // none selection state - } - - if isReveal { - return isSelect ? .white : .clear - } else { - return Asset.Colors.hightLight.color - } - } - .assign(to: &$selectImageTintColor) - // isReveal - Publishers.CombineLatest3( - $isExpire, - $isPollVoted, - $isMyPoll - ) - .map { isExpire, isPollVoted, isMyPoll in - return isExpire || isPollVoted || isMyPoll - } - .assign(to: &$isReveal) - // groupedAccessibilityLabel - - Publishers.CombineLatest3( - $metaContent, - $percentage, - $isReveal - ) - .map { metaContent, percentage, isReveal -> String in - var strings: [String?] = [] - - metaContent.flatMap { strings.append($0.string) } - - if isReveal, - let percentage = percentage, - let string = PollOptionView.percentageFormatter.string(from: NSNumber(value: percentage)) - { - strings.append(string) - } - - return strings.compactMap { $0 }.joined(separator: ", ") - } - .assign(to: &$groupedAccessibilityLabel) - } - - public enum Corner: Hashable { - case none - case circle - case radius(CGFloat) - } - } -} - -extension PollOptionView.ViewModel { - public func bind(view: PollOptionView) { - // content - NotificationCenter.default - .publisher(for: UITextField.textDidChangeNotification, object: view.textField) - .receive(on: DispatchQueue.main) - .map { _ in view.textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" } - .assign(to: &$content) - // metaContent - $metaContent - .sink { metaContent in - guard let metaContent = metaContent else { - view.titleMetaLabel.reset() - return - } - view.titleMetaLabel.configure(content: metaContent) - } - .store(in: &disposeBag) - // percentage - Publishers.CombineLatest( - $isReveal, - $percentage - ) - .sink { isReveal, percentage in - guard isReveal else { - view.percentageMetaLabel.configure(content: PlaintextMetaContent(string: "")) - return - } - - let oldPercentage = self.percentage - - let animated = oldPercentage != nil && percentage != nil - view.stripProgressView.setProgress(percentage ?? 0, animated: animated) - - guard let percentage = percentage, - let string = PollOptionView.percentageFormatter.string(from: NSNumber(value: percentage)) - else { - view.percentageMetaLabel.configure(content: PlaintextMetaContent(string: "")) - return - } - - view.percentageMetaLabel.configure(content: PlaintextMetaContent(string: string)) - } - .store(in: &disposeBag) - // corner - $corner - .removeDuplicates() - .sink { _ in - view.setNeedsLayout() - } - .store(in: &disposeBag) - // backgroundColor - $stripProgressTinitColor - .map { $0 as UIColor? } - .assign(to: \.tintColor, on: view.stripProgressView) - .store(in: &disposeBag) - // selectionImageView - Publishers.CombineLatest4( - $style, - $isMultiple, - $isSelect, - $isReveal - ) - .map { style, isMultiple, isSelect, isReveal -> UIImage? in - guard case .plain = style else { return nil } - - func circle(isSelect: Bool) -> UIImage { - let image = isSelect ? Asset.Indices.checkmarkCircleFill.image : Asset.Indices.circle.image - return image.withRenderingMode(.alwaysTemplate) - } - - func square(isSelect: Bool) -> UIImage { - let image = isSelect ? Asset.Indices.checkmarkSquareFill.image : Asset.Indices.square.image - return image.withRenderingMode(.alwaysTemplate) - } - - func image(isMultiple: Bool, isSelect: Bool) -> UIImage { - return isMultiple ? square(isSelect: isSelect) : circle(isSelect: isSelect) - } - - if isReveal { - guard isSelect == true else { - // not display image when isReveal: - // - the server not return selection state - // - the user not select - return nil - } - return image(isMultiple: isMultiple, isSelect: true) - } else { - return image(isMultiple: isMultiple, isSelect: isSelect == true) - } - } - .sink { image in - view.selectionImageView.image = image - } - .store(in: &disposeBag) - // selectImageTintColor - $selectImageTintColor - .assign(to: \.tintColor, on: view.selectionImageView) - .store(in: &disposeBag) - // accessibility - $isSelect - .sink { isSelect in - if isSelect == true { - view.accessibilityTraits.insert(.selected) - } else { - view.accessibilityTraits.remove(.selected) - } - } - .store(in: &disposeBag) - $groupedAccessibilityLabel - .sink { groupedAccessibilityLabel in - view.accessibilityLabel = groupedAccessibilityLabel - } - .store(in: &disposeBag) - } -} +//extension PollOptionView { +// +// static let percentageFormatter: NumberFormatter = { +// let formatter = NumberFormatter() +// formatter.numberStyle = .percent +// formatter.maximumFractionDigits = 1 +// formatter.minimumIntegerDigits = 1 +// formatter.roundingMode = .down +// return formatter +// }() +// +// public final class ViewModel: ObservableObject { +// var disposeBag = Set() +// var observations = Set() +// var objects = Set() +// +// @Published var authenticationContext: AuthenticationContext? +// +// @Published var style: PollOptionView.Style? +// +// @Published public var content: String = "" // for edit style +// +// @Published public var metaContent: MetaContent? // for plain style +// @Published public var percentage: Double? +// +// @Published public var isExpire: Bool = false +// @Published public var isMultiple: Bool = false +// @Published public var isSelect: Bool? = false // nil for server not return selection array +// @Published public var isPollVoted: Bool = false +// @Published public var isMyPoll: Bool = false +// +// // output +// @Published public var corner: Corner = .none +// @Published public var stripProgressTinitColor: UIColor = .clear +// @Published public var selectImageTintColor: UIColor = Asset.Colors.hightLight.color +// @Published public var isReveal: Bool = false +// +// @Published public var groupedAccessibilityLabel = "" +// +// init() { +// // corner +// $isMultiple +// .map { $0 ? .radius(8) : .circle } +// .assign(to: &$corner) +// // stripProgressTinitColor +// Publishers.CombineLatest3( +// $style, +// $isSelect, +// $isReveal +// ) +// .map { style, isSelect, isReveal -> UIColor in +// guard case .plain = style else { return .clear } +// guard isReveal else { +// return .clear +// } +// +// if isSelect == true { +// return Asset.Colors.hightLight.color.withAlphaComponent(0.75) +// } else { +// return Asset.Colors.hightLight.color.withAlphaComponent(0.20) +// } +// } +// .assign(to: &$stripProgressTinitColor) +// // selectImageTintColor +// Publishers.CombineLatest( +// $isSelect, +// $isReveal +// ) +// .map { isSelect, isReveal in +// guard let isSelect = isSelect else { +// return .clear // none selection state +// } +// +// if isReveal { +// return isSelect ? .white : .clear +// } else { +// return Asset.Colors.hightLight.color +// } +// } +// .assign(to: &$selectImageTintColor) +// // isReveal +// Publishers.CombineLatest3( +// $isExpire, +// $isPollVoted, +// $isMyPoll +// ) +// .map { isExpire, isPollVoted, isMyPoll in +// return isExpire || isPollVoted || isMyPoll +// } +// .assign(to: &$isReveal) +// // groupedAccessibilityLabel +// +// Publishers.CombineLatest3( +// $metaContent, +// $percentage, +// $isReveal +// ) +// .map { metaContent, percentage, isReveal -> String in +// var strings: [String?] = [] +// +// metaContent.flatMap { strings.append($0.string) } +// +// if isReveal, +// let percentage = percentage, +// let string = PollOptionView.percentageFormatter.string(from: NSNumber(value: percentage)) +// { +// strings.append(string) +// } +// +// return strings.compactMap { $0 }.joined(separator: ", ") +// } +// .assign(to: &$groupedAccessibilityLabel) +// } +// +// public enum Corner: Hashable { +// case none +// case circle +// case radius(CGFloat) +// } +// } +//} +// +//extension PollOptionView.ViewModel { +// public func bind(view: PollOptionView) { +// // content +// NotificationCenter.default +// .publisher(for: UITextField.textDidChangeNotification, object: view.textField) +// .receive(on: DispatchQueue.main) +// .map { _ in view.textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" } +// .assign(to: &$content) +// // metaContent +// $metaContent +// .sink { metaContent in +// guard let metaContent = metaContent else { +// view.titleMetaLabel.reset() +// return +// } +// view.titleMetaLabel.configure(content: metaContent) +// } +// .store(in: &disposeBag) +// // percentage +// Publishers.CombineLatest( +// $isReveal, +// $percentage +// ) +// .sink { isReveal, percentage in +// guard isReveal else { +// view.percentageMetaLabel.configure(content: PlaintextMetaContent(string: "")) +// return +// } +// +// let oldPercentage = self.percentage +// +// let animated = oldPercentage != nil && percentage != nil +// view.stripProgressView.setProgress(percentage ?? 0, animated: animated) +// +// guard let percentage = percentage, +// let string = PollOptionView.percentageFormatter.string(from: NSNumber(value: percentage)) +// else { +// view.percentageMetaLabel.configure(content: PlaintextMetaContent(string: "")) +// return +// } +// +// view.percentageMetaLabel.configure(content: PlaintextMetaContent(string: string)) +// } +// .store(in: &disposeBag) +// // corner +// $corner +// .removeDuplicates() +// .sink { _ in +// view.setNeedsLayout() +// } +// .store(in: &disposeBag) +// // backgroundColor +// $stripProgressTinitColor +// .map { $0 as UIColor? } +// .assign(to: \.tintColor, on: view.stripProgressView) +// .store(in: &disposeBag) +// // selectionImageView +// Publishers.CombineLatest4( +// $style, +// $isMultiple, +// $isSelect, +// $isReveal +// ) +// .map { style, isMultiple, isSelect, isReveal -> UIImage? in +// guard case .plain = style else { return nil } +// +// func circle(isSelect: Bool) -> UIImage { +// let image = isSelect ? Asset.Indices.checkmarkCircleFill.image : Asset.Indices.circle.image +// return image.withRenderingMode(.alwaysTemplate) +// } +// +// func square(isSelect: Bool) -> UIImage { +// let image = isSelect ? Asset.Indices.checkmarkSquareFill.image : Asset.Indices.square.image +// return image.withRenderingMode(.alwaysTemplate) +// } +// +// func image(isMultiple: Bool, isSelect: Bool) -> UIImage { +// return isMultiple ? square(isSelect: isSelect) : circle(isSelect: isSelect) +// } +// +// if isReveal { +// guard isSelect == true else { +// // not display image when isReveal: +// // - the server not return selection state +// // - the user not select +// return nil +// } +// return image(isMultiple: isMultiple, isSelect: true) +// } else { +// return image(isMultiple: isMultiple, isSelect: isSelect == true) +// } +// } +// .sink { image in +// view.selectionImageView.image = image +// } +// .store(in: &disposeBag) +// // selectImageTintColor +// $selectImageTintColor +// .assign(to: \.tintColor, on: view.selectionImageView) +// .store(in: &disposeBag) +// // accessibility +// $isSelect +// .sink { isSelect in +// if isSelect == true { +// view.accessibilityTraits.insert(.selected) +// } else { +// view.accessibilityTraits.remove(.selected) +// } +// } +// .store(in: &disposeBag) +// $groupedAccessibilityLabel +// .sink { groupedAccessibilityLabel in +// view.accessibilityLabel = groupedAccessibilityLabel +// } +// .store(in: &disposeBag) +// } +//} diff --git a/TwidereSDK/Sources/TwidereUI/Content/PollOptionView.swift b/TwidereSDK/Sources/TwidereUI/Content/PollOptionView.swift index b4a25523..d3af2b19 100644 --- a/TwidereSDK/Sources/TwidereUI/Content/PollOptionView.swift +++ b/TwidereSDK/Sources/TwidereUI/Content/PollOptionView.swift @@ -5,256 +5,463 @@ // Created by MainasuK on 2021-11-29. // -import UIKit +import os.log +import Foundation +import SwiftUI import Combine -import MetaTextKit -import TwidereLocalization -import UITextView_Placeholder -import TwidereCore - -public protocol PollOptionViewDelegate: AnyObject { - func pollOptionView(_ pollOptionView: PollOptionView, deleteBackwardResponseTextField textField: DeleteBackwardResponseTextField, textBeforeDelete: String?) -} +import MastodonMeta +import CoreDataStack -public final class PollOptionView: UIView { - - static let height: CGFloat = 36 - - public weak var delegate: PollOptionViewDelegate? - private(set) var style: Style? - - var disposeBag = Set() - public private(set) lazy var viewModel: ViewModel = { - let viewModel = ViewModel() - viewModel.bind(view: self) - return viewModel - }() - - let containerView = UIView() - - let stripProgressView = StripProgressView() - - let selectionImageView: UIImageView = { - let imageView = UIImageView() - return imageView - }() - - public let titleMetaLabel = MetaLabel(style: .pollOptionTitle) - - public let percentageMetaLabel = MetaLabel(style: .pollOptionPercentage) - - // TODO: MetaTextField? - public let textField: DeleteBackwardResponseTextField = { - let textField = DeleteBackwardResponseTextField() - textField.font = .systemFont(ofSize: 16, weight: .regular) - textField.textColor = .label - textField.text = "Choice" - textField.textAlignment = UIApplication.shared.userInterfaceLayoutDirection == .leftToRight ? .left : .right - return textField - }() +public struct PollOptionView: View { - public func prepareForReuse() { - viewModel.objects.removeAll() - viewModel.percentage = nil - stripProgressView.setProgress(0, animated: false) - } - - public override init(frame: CGRect) { - super.init(frame: frame) - _init() - } - - public required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } - -} - -extension PollOptionView { + @ObservedObject public var viewModel: ViewModel + public let selectAction: (ViewModel) -> Void - private func _init() { - textField.deleteBackwardDelegate = self - - // Accessibility - // hint: Poll option - accessibilityHint = L10n.Accessibility.Common.Status.pollOptionOrdinalPrefix + var bodyFont: UIFont { TextStyle.pollOptionTitle.font } + var rowHeight: CGFloat { + let height = abs(bodyFont.ascender) + abs(bodyFont.descender) + return max(markViewMinHeight + 2 * markViewPadding, height) } + var markViewMinHeight: CGFloat { 20.0 } + var markViewPadding: CGFloat { 4.0 } - public override func layoutSubviews() { - super.layoutSubviews() - - setupCorner() - } - - func setupCorner() { - switch viewModel.corner { - case .none: - containerView.layer.masksToBounds = false - stripProgressView.cornerRadius = 0 - case .radius(let radius): - containerView.layer.masksToBounds = true - guard radius < bounds.height / 2 else { - fallthrough - } - containerView.layer.cornerCurve = .continuous - containerView.layer.cornerRadius = radius - stripProgressView.cornerRadius = radius - case .circle: - let radius = bounds.height / 2 - containerView.layer.masksToBounds = true - containerView.layer.cornerCurve = .circular - containerView.layer.cornerRadius = radius - stripProgressView.cornerRadius = radius - } - } - - public func setup(style: Style) { - guard self.style == nil else { - assertionFailure("Should only setup once") - return - } - self.style = style - self.viewModel.style = style - style.layout(view: self) - } - - public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) { - textField.layer.borderColor = UIColor.secondaryLabel.cgColor + var markView: some View { + GeometryReader { proxy in + let tintColor = viewModel.canSelect ? Asset.Colors.hightLight.color : .systemBackground + let dimension = proxy.size.width + CheckmarkView( + tintColor: tintColor, + borderWidth: ceil(dimension / 15), + cornerRadius: viewModel.isMulitpleChoice ? dimension / 6 : dimension / 2, + check: viewModel.isOptionVoted || viewModel.isSelected + ) } } -} - -extension PollOptionView { - public enum Style { - case plain - case edit - - func layout(view: PollOptionView) { - switch self { - case .plain: layoutPlain(view: view) - case .edit: layoutEdit(view: view) + public var body: some View { + Button { + selectAction(viewModel) + } label: { + let rowHeight = self.rowHeight + let rowCornerRadius: CGFloat = { + if viewModel.isMulitpleChoice { + return rowHeight / 6 + } else { + return rowHeight / 2 + } + }() + HStack(spacing: .zero) { + markView + .padding(markViewPadding) + .frame(width: rowHeight, height: rowHeight) + .opacity(viewModel.canSelect || viewModel.isOptionVoted ? 1 : 0) + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .center) { + LabelRepresentable( + metaContent: viewModel.content, + textStyle: .pollOptionTitle, + setupLabel: { label in + label.setContentHuggingPriority(.required, for: .horizontal) + label.setContentCompressionResistancePriority(.required, for: .horizontal) + } + ) + .fixedSize(horizontal: false, vertical: true) + Spacer() + } + } // end ScrollView + // TODO: https://developer.apple.com/documentation/swiftui/view/scrollbouncebehavior(_:axes:)?changes=latest_minor + Text(viewModel.percentageText) + .font(Font(bodyFont)) + .foregroundColor(.secondary) + .monospacedDigit() + .padding(.horizontal, 6) + .opacity(viewModel.isResultReveal ? 1 : 0) } + .frame(height: rowHeight) + .background( + GeometryReader { proxy in + ZStack(alignment: .leading) { + Color(uiColor: Asset.Colors.hightLight.color.withAlphaComponent(0.15)) + // note: + // Use offset method to keep the perfect circle shape on edges. + // So the edge of the bar with percenage likes 0.1 will display as circle + // but not rounded square + let alpha = viewModel.isOptionVoted ? 0.75 : 0.25 + let color = Asset.Colors.hightLight.color.withAlphaComponent(alpha) + let offsetX = proxy.size.width * (1 - viewModel.percentage) + Color(uiColor: color) + .cornerRadius(rowCornerRadius) + .offset(x: -offsetX) // tweak position + .animation(.easeInOut, value: viewModel.percentage) + .opacity(viewModel.isResultReveal ? 1 : 0) + } + .compositingGroup() + .cornerRadius(rowCornerRadius) // clip + } + ) } + .buttonStyle(.borderless) } } -extension PollOptionView.Style { - private func layoutPlain(view: PollOptionView) { - view.containerView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(view.containerView) - NSLayoutConstraint.activate([ - view.containerView.topAnchor.constraint(equalTo: view.topAnchor), - view.containerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - view.containerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - view.containerView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ]) - view.containerView.backgroundColor = Asset.Colors.hightLight.color.withAlphaComponent(0.08) +extension PollOptionView { + public class ViewModel: ObservableObject, Identifiable { - view.stripProgressView.translatesAutoresizingMaskIntoConstraints = false - view.containerView.addSubview(view.stripProgressView) - NSLayoutConstraint.activate([ - view.stripProgressView.topAnchor.constraint(equalTo: view.containerView.topAnchor), - view.stripProgressView.leadingAnchor.constraint(equalTo: view.containerView.leadingAnchor), - view.stripProgressView.trailingAnchor.constraint(equalTo: view.containerView.trailingAnchor), - view.stripProgressView.bottomAnchor.constraint(equalTo: view.containerView.bottomAnchor), - ]) + public var id: Int { index } - view.selectionImageView.translatesAutoresizingMaskIntoConstraints = false - view.containerView.addSubview(view.selectionImageView) - NSLayoutConstraint.activate([ - view.selectionImageView.topAnchor.constraint(equalTo: view.containerView.topAnchor, constant: 6), - view.selectionImageView.leadingAnchor.constraint(equalTo: view.containerView.leadingAnchor, constant: 6), - view.containerView.bottomAnchor.constraint(equalTo: view.selectionImageView.bottomAnchor, constant: 6), - view.selectionImageView.widthAnchor.constraint(equalToConstant: 24).priority(.required - 1), - view.selectionImageView.heightAnchor.constraint(equalToConstant: 24).priority(.required - 1), - ]) + // input + private let authContext: AuthContext? + @MainActor private let pollOption: PollOptionObject - view.titleMetaLabel.translatesAutoresizingMaskIntoConstraints = false - view.containerView.addSubview(view.titleMetaLabel) - NSLayoutConstraint.activate([ - view.titleMetaLabel.leadingAnchor.constraint(equalTo: view.selectionImageView.trailingAnchor, constant: 4), - view.titleMetaLabel.centerYAnchor.constraint(equalTo: view.containerView.centerYAnchor), - ]) - view.titleMetaLabel.setContentHuggingPriority(.defaultLow - 10, for: .horizontal) + public let index: Int + public let content: MetaContent + public let isMulitpleChoice: Bool + public let isMyself: Bool - view.percentageMetaLabel.translatesAutoresizingMaskIntoConstraints = false - view.containerView.addSubview(view.percentageMetaLabel) - NSLayoutConstraint.activate([ - view.percentageMetaLabel.leadingAnchor.constraint(equalTo: view.titleMetaLabel.trailingAnchor, constant: 4), - view.containerView.trailingAnchor.constraint(equalTo: view.percentageMetaLabel.trailingAnchor, constant: 8), - view.percentageMetaLabel.centerYAnchor.constraint(equalTo: view.containerView.centerYAnchor), - ]) - view.percentageMetaLabel.setContentHuggingPriority(.required - 10, for: .horizontal) - view.percentageMetaLabel.setContentCompressionResistancePriority(.required - 2, for: .horizontal) + @Published public var isClosed = false + @Published public var totalVotes: Int = 0 + @Published public var votes: Int = 0 + @Published public var isOptionVoted = false + @Published public var isPollVoted = false + @Published public var isSelected: Bool = false - view.titleMetaLabel.isUserInteractionEnabled = false - view.percentageMetaLabel.isUserInteractionEnabled = false - } - - private func layoutEdit(view: PollOptionView) { - view.containerView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(view.containerView) - NSLayoutConstraint.activate([ - view.containerView.topAnchor.constraint(equalTo: view.topAnchor), - view.containerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - view.containerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - view.containerView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ]) + public var canSelect: Bool { + if isMyself { return false } + if isClosed { return false } + if case .twitter = pollOption { return false } + if isPollVoted || isOptionVoted { return false } + return true + } + public var isResultReveal: Bool { + return !canSelect + } - view.textField.translatesAutoresizingMaskIntoConstraints = false - view.containerView.addSubview(view.textField) - NSLayoutConstraint.activate([ - view.textField.topAnchor.constraint(equalTo: view.containerView.topAnchor), - view.textField.leadingAnchor.constraint(equalTo: view.containerView.leadingAnchor), - view.textField.trailingAnchor.constraint(equalTo: view.containerView.trailingAnchor), - view.textField.bottomAnchor.constraint(equalTo: view.containerView.bottomAnchor), - ]) + // output + private static let percentageFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.minimumFractionDigits = 0 + formatter.maximumFractionDigits = 2 + formatter.numberStyle = .percent + return formatter + }() + public var percentage: Double { + guard totalVotes > 0 else { return 0.0 } + return Double(votes) / Double(totalVotes) + } + public var percentageText: String { + let _text = Self.percentageFormatter.string(from: NSNumber(value: percentage)) ?? nil + return _text ?? "" + } - view.containerView.layer.masksToBounds = true - view.containerView.layer.cornerRadius = 6 - view.containerView.layer.cornerCurve = .continuous - view.containerView.layer.borderColor = UIColor.secondaryLabel.cgColor - view.containerView.layer.borderWidth = UIView.separatorLineHeight(of: view) - } - -} - -// MARK; - DeleteBackwardResponseTextFieldDelegate -extension PollOptionView: DeleteBackwardResponseTextFieldDelegate { - public func deleteBackwardResponseTextField(_ textField: DeleteBackwardResponseTextField, textBeforeDelete: String?) { - delegate?.pollOptionView(self, deleteBackwardResponseTextField: textField, textBeforeDelete: textBeforeDelete) - } -} - -#if DEBUG -import SwiftUI -struct PollOptionView_Preview: PreviewProvider { - static var previews: some View { - Group { - UIViewPreview(width: 400, height: 36) { - let pollOptionView = PollOptionView() - pollOptionView.setup(style: .edit) - return pollOptionView + public init( + authContext: AuthContext?, + pollOption: PollOptionObject, + isMyself: Bool + ) { + self.authContext = authContext + self.pollOption = pollOption + self.isMyself = isMyself + + assert(Thread.isMainThread) + switch pollOption { + case .twitter(let option): + index = Int(option.position) + content = PlaintextMetaContent(string: option.label) + isClosed = true // cannot vote for Twitter + isMulitpleChoice = false + isSelected = false + votes = Int(option.votes) + option.publisher(for: \.votes) + .map { Int($0) } + .assign(to: &$votes) + case .mastodon(let option): + index = Int(option.index) + content = { + do { + let content = MastodonContent(content: option.title, emojis: option.poll.status.emojisTransient.asDictionary) + let metaContent = try MastodonMetaContent.convert(document: content) + return metaContent + } catch { + return PlaintextMetaContent(string: option.title) + } + }() + isMulitpleChoice = option.poll.multiple + option.poll.publisher(for: \.expired) + .assign(to: &$isClosed) + votes = Int(option.votesCount) + option.publisher(for: \.votesCount) + .map { Int($0) } + .assign(to: &$votes) + option.publisher(for: \.isSelected) + .assign(to: &$isSelected) } - .frame(width: 400, height: 36) - .padding(10) - .previewLayout(.sizeThatFits) - .previewDisplayName("Edit") - UIViewPreview(width: 400, height: 36) { - let pollOptionView = PollOptionView() - pollOptionView.setup(style: .plain) - return pollOptionView + + switch (authContext?.authenticationContext, pollOption) { + case (.twitter, .twitter): + break + case (.mastodon(let authenticationContext), .mastodon(let option)): + // bind isVoted + option.publisher(for: \.voteBy) + .map { voteBy in + voteBy.contains(where: { $0.id == authenticationContext.userID && $0.domain == authenticationContext.domain }) + } + .assign(to: &$isOptionVoted) + option.poll.publisher(for: \.voteBy) + .map { voteBy in + voteBy.contains(where: { $0.id == authenticationContext.userID && $0.domain == authenticationContext.domain }) + } + .assign(to: &$isPollVoted) + default: + break } - .frame(width: 400, height: 36) - .padding(10) - .previewLayout(.sizeThatFits) - .previewDisplayName("Plain") - } - } + } // end init + } // end class } -#endif + + +//public protocol PollOptionViewDelegate: AnyObject { +// func pollOptionView(_ pollOptionView: PollOptionView, deleteBackwardResponseTextField textField: DeleteBackwardResponseTextField, textBeforeDelete: String?) +//} +// +//public final class PollOptionView: UIView { +// +// static let height: CGFloat = 36 +// +// public weak var delegate: PollOptionViewDelegate? +// private(set) var style: Style? +// +// var disposeBag = Set() +// public private(set) lazy var viewModel: ViewModel = { +// let viewModel = ViewModel() +// viewModel.bind(view: self) +// return viewModel +// }() +// +// let containerView = UIView() +// +// let stripProgressView = StripProgressView() +// +// let selectionImageView: UIImageView = { +// let imageView = UIImageView() +// return imageView +// }() +// +// public let titleMetaLabel = MetaLabel(style: .pollOptionTitle) +// +// public let percentageMetaLabel = MetaLabel(style: .pollOptionPercentage) +// +// // TODO: MetaTextField? +// public let textField: DeleteBackwardResponseTextField = { +// let textField = DeleteBackwardResponseTextField() +// textField.font = .systemFont(ofSize: 16, weight: .regular) +// textField.textColor = .label +// textField.text = "Choice" +// textField.textAlignment = UIApplication.shared.userInterfaceLayoutDirection == .leftToRight ? .left : .right +// return textField +// }() +// +// public func prepareForReuse() { +// viewModel.objects.removeAll() +// viewModel.percentage = nil +// stripProgressView.setProgress(0, animated: false) +// } +// +// public override init(frame: CGRect) { +// super.init(frame: frame) +// _init() +// } +// +// public required init?(coder: NSCoder) { +// super.init(coder: coder) +// _init() +// } +// +//} +// +//extension PollOptionView { +// +// private func _init() { +// textField.deleteBackwardDelegate = self +// +// // Accessibility +// // hint: Poll option +// accessibilityHint = L10n.Accessibility.Common.Status.pollOptionOrdinalPrefix +// } +// +// public override func layoutSubviews() { +// super.layoutSubviews() +// +// setupCorner() +// } +// +// func setupCorner() { +// switch viewModel.corner { +// case .none: +// containerView.layer.masksToBounds = false +// stripProgressView.cornerRadius = 0 +// case .radius(let radius): +// containerView.layer.masksToBounds = true +// guard radius < bounds.height / 2 else { +// fallthrough +// } +// containerView.layer.cornerCurve = .continuous +// containerView.layer.cornerRadius = radius +// stripProgressView.cornerRadius = radius +// case .circle: +// let radius = bounds.height / 2 +// containerView.layer.masksToBounds = true +// containerView.layer.cornerCurve = .circular +// containerView.layer.cornerRadius = radius +// stripProgressView.cornerRadius = radius +// } +// } +// +// public func setup(style: Style) { +// guard self.style == nil else { +// assertionFailure("Should only setup once") +// return +// } +// self.style = style +// self.viewModel.style = style +// style.layout(view: self) +// } +// +// public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { +// super.traitCollectionDidChange(previousTraitCollection) +// +// if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) { +// textField.layer.borderColor = UIColor.secondaryLabel.cgColor +// } +// } +// +//} +// +//extension PollOptionView { +// public enum Style { +// case plain +// case edit +// +// func layout(view: PollOptionView) { +// switch self { +// case .plain: layoutPlain(view: view) +// case .edit: layoutEdit(view: view) +// } +// } +// } +//} +// +//extension PollOptionView.Style { +// private func layoutPlain(view: PollOptionView) { +// view.containerView.translatesAutoresizingMaskIntoConstraints = false +// view.addSubview(view.containerView) +// NSLayoutConstraint.activate([ +// view.containerView.topAnchor.constraint(equalTo: view.topAnchor), +// view.containerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), +// view.containerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), +// view.containerView.bottomAnchor.constraint(equalTo: view.bottomAnchor), +// ]) +// view.containerView.backgroundColor = Asset.Colors.hightLight.color.withAlphaComponent(0.08) +// +// view.stripProgressView.translatesAutoresizingMaskIntoConstraints = false +// view.containerView.addSubview(view.stripProgressView) +// NSLayoutConstraint.activate([ +// view.stripProgressView.topAnchor.constraint(equalTo: view.containerView.topAnchor), +// view.stripProgressView.leadingAnchor.constraint(equalTo: view.containerView.leadingAnchor), +// view.stripProgressView.trailingAnchor.constraint(equalTo: view.containerView.trailingAnchor), +// view.stripProgressView.bottomAnchor.constraint(equalTo: view.containerView.bottomAnchor), +// ]) +// +// view.selectionImageView.translatesAutoresizingMaskIntoConstraints = false +// view.containerView.addSubview(view.selectionImageView) +// NSLayoutConstraint.activate([ +// view.selectionImageView.topAnchor.constraint(equalTo: view.containerView.topAnchor, constant: 6), +// view.selectionImageView.leadingAnchor.constraint(equalTo: view.containerView.leadingAnchor, constant: 6), +// view.containerView.bottomAnchor.constraint(equalTo: view.selectionImageView.bottomAnchor, constant: 6), +// view.selectionImageView.widthAnchor.constraint(equalToConstant: 24).priority(.required - 1), +// view.selectionImageView.heightAnchor.constraint(equalToConstant: 24).priority(.required - 1), +// ]) +// +// view.titleMetaLabel.translatesAutoresizingMaskIntoConstraints = false +// view.containerView.addSubview(view.titleMetaLabel) +// NSLayoutConstraint.activate([ +// view.titleMetaLabel.leadingAnchor.constraint(equalTo: view.selectionImageView.trailingAnchor, constant: 4), +// view.titleMetaLabel.centerYAnchor.constraint(equalTo: view.containerView.centerYAnchor), +// ]) +// view.titleMetaLabel.setContentHuggingPriority(.defaultLow - 10, for: .horizontal) +// +// view.percentageMetaLabel.translatesAutoresizingMaskIntoConstraints = false +// view.containerView.addSubview(view.percentageMetaLabel) +// NSLayoutConstraint.activate([ +// view.percentageMetaLabel.leadingAnchor.constraint(equalTo: view.titleMetaLabel.trailingAnchor, constant: 4), +// view.containerView.trailingAnchor.constraint(equalTo: view.percentageMetaLabel.trailingAnchor, constant: 8), +// view.percentageMetaLabel.centerYAnchor.constraint(equalTo: view.containerView.centerYAnchor), +// ]) +// view.percentageMetaLabel.setContentHuggingPriority(.required - 10, for: .horizontal) +// view.percentageMetaLabel.setContentCompressionResistancePriority(.required - 2, for: .horizontal) +// +// view.titleMetaLabel.isUserInteractionEnabled = false +// view.percentageMetaLabel.isUserInteractionEnabled = false +// } +// +// private func layoutEdit(view: PollOptionView) { +// view.containerView.translatesAutoresizingMaskIntoConstraints = false +// view.addSubview(view.containerView) +// NSLayoutConstraint.activate([ +// view.containerView.topAnchor.constraint(equalTo: view.topAnchor), +// view.containerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), +// view.containerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), +// view.containerView.bottomAnchor.constraint(equalTo: view.bottomAnchor), +// ]) +// +// view.textField.translatesAutoresizingMaskIntoConstraints = false +// view.containerView.addSubview(view.textField) +// NSLayoutConstraint.activate([ +// view.textField.topAnchor.constraint(equalTo: view.containerView.topAnchor), +// view.textField.leadingAnchor.constraint(equalTo: view.containerView.leadingAnchor), +// view.textField.trailingAnchor.constraint(equalTo: view.containerView.trailingAnchor), +// view.textField.bottomAnchor.constraint(equalTo: view.containerView.bottomAnchor), +// ]) +// +// view.containerView.layer.masksToBounds = true +// view.containerView.layer.cornerRadius = 6 +// view.containerView.layer.cornerCurve = .continuous +// view.containerView.layer.borderColor = UIColor.secondaryLabel.cgColor +// view.containerView.layer.borderWidth = UIView.separatorLineHeight(of: view) +// } +// +//} +// +//// MARK; - DeleteBackwardResponseTextFieldDelegate +//extension PollOptionView: DeleteBackwardResponseTextFieldDelegate { +// public func deleteBackwardResponseTextField(_ textField: DeleteBackwardResponseTextField, textBeforeDelete: String?) { +// delegate?.pollOptionView(self, deleteBackwardResponseTextField: textField, textBeforeDelete: textBeforeDelete) +// } +//} +// +//#if DEBUG +//import SwiftUI +//struct PollOptionView_Preview: PreviewProvider { +// static var previews: some View { +// Group { +// UIViewPreview(width: 400, height: 36) { +// let pollOptionView = PollOptionView() +// pollOptionView.setup(style: .edit) +// return pollOptionView +// } +// .frame(width: 400, height: 36) +// .padding(10) +// .previewLayout(.sizeThatFits) +// .previewDisplayName("Edit") +// UIViewPreview(width: 400, height: 36) { +// let pollOptionView = PollOptionView() +// pollOptionView.setup(style: .plain) +// return pollOptionView +// } +// .frame(width: 400, height: 36) +// .padding(10) +// .previewLayout(.sizeThatFits) +// .previewDisplayName("Plain") +// } +// } +//} +//#endif diff --git a/TwidereSDK/Sources/TwidereUI/Content/PollView.swift b/TwidereSDK/Sources/TwidereUI/Content/PollView.swift new file mode 100644 index 00000000..4675d57c --- /dev/null +++ b/TwidereSDK/Sources/TwidereUI/Content/PollView.swift @@ -0,0 +1,289 @@ +// +// PollView.swift +// +// +// Created by MainasuK on 2023/3/21. +// + +import os.log +import Foundation +import SwiftUI +import Combine +import MastodonMeta +import CoreDataStack + +public struct PollView: View { + + static let logger = Logger(subsystem: "PollView", category: "View") + var logger: Logger { PollView.logger } + + @ObservedObject public var viewModel: ViewModel + public let selectAction: (PollOptionView.ViewModel) -> Void + public let voteAction: (ViewModel) -> Void + + public var body: some View { + VStack(spacing: 12) { + ForEach(viewModel.options) { option in + PollOptionView(viewModel: option) { optionViewModel in + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(optionViewModel.index)") + selectAction(optionViewModel) + } +// VStack { +// HStack { +// Text(option.content) +// .font(.system(size: 16, weight: .regular)) +// .foregroundColor(Color(uiColor: Asset.Color.M3.Sys.onSurface.color)) +// Spacer() +// } +// HStack(alignment: .center) { +// GeometryReader { proxy in +// RoundedRectangle(cornerRadius: proxy.size.height / 2) +// .frame(width: proxy.size.width) +// .foregroundColor(Color(uiColor: Asset.Color.M3.Sys.surfaceVariant.color)) +// .overlay( +// HStack { +// let gradient = Gradient(stops: [ +// .init(color: Color(uiColor: UIColor(hex: 0xFF7575)), location: 0.0), +// .init(color: Color(uiColor: UIColor(hex: 0x8B54FF)), location: 1.0), +// ]) +// LinearGradient(gradient: gradient, startPoint: .leading, endPoint: .trailing) +// .foregroundColor(Color(uiColor: Asset.Color.Sys.primary.color)) +// .mask(alignment: .leading) { +// RoundedRectangle(cornerRadius: proxy.size.height / 2) +// .frame(width: proxy.size.width * (option.percentage ?? 0)) +// } +// } +// .frame(alignment: .leading) +// ) +// .clipShape( +// RoundedRectangle(cornerRadius: proxy.size.height / 2) +// ) +// } +// .frame(height: 12) +// Text("99.99%") // fixed width size +// .lineLimit(1) +// .font(.system(size: 14, weight: .regular)) +// .foregroundColor(.clear) +// .overlay( +// HStack(spacing: .zero) { +// Spacer() +// Text(option.percentageText) +// .lineLimit(1) +// .font(.system(size: 14, weight: .regular)) +// .foregroundColor(Color(uiColor: Asset.Color.secondary.color)) +// .fixedSize(horizontal: true, vertical: false) +// } +// ) +// } +// } + } // end ForEach + HStack { + Text(verbatim: viewModel.pollDescription) + .font(Font(TextStyle.pollVoteDescription.font)) + .lineLimit(TextStyle.pollVoteDescription.numberOfLines) + .foregroundColor(Color(uiColor: TextStyle.pollVoteDescription.textColor)) + Spacer() + if viewModel.isVoteButtonDisplay { + Button { + guard viewModel.isVoteButtonEnabled else { return } + guard !viewModel.isVoting else { return } + voteAction(viewModel) + } label: { + let textColor = viewModel.isVoteButtonEnabled ? TextStyle.pollVoteButton.textColor : .secondaryLabel + Text(L10n.Common.Controls.Status.Actions.vote) + .font(Font(TextStyle.pollVoteButton.font)) + .lineLimit(TextStyle.pollVoteButton.numberOfLines) + .foregroundColor(Color(uiColor: textColor)) + .opacity(viewModel.isVoting ? 0 : 1) + .overlay { + if viewModel.isVoting { + ProgressView() + .progressViewStyle(.circular) + .tint(.secondary) + } + } + } + .buttonStyle(.borderless) + } + } + } // end VStack + } + + var pollDescriptionLabelTintColor: Color { + let color = UIColor { traitCollection in + switch traitCollection.userInterfaceStyle { + // TODO: use new color palate + case .light: return UIColor(hex: 0xABABA9) + default: return UIColor(hex: 0x5A5E6C) + } + } + return Color(uiColor: color) + } + +} + +extension PollView { + public class ViewModel: ObservableObject { + + var disposeBag = Set() + + // input + private let authContext: AuthContext? + @MainActor private let poll: PollObject + + public let platform: Platform + public let endDate: Date? + public let isMyself: Bool + + @Published public var options: [PollOptionView.ViewModel] = [] + @Published public var isClosed = false + @Published public var isVoting = false + @Published public var isPollVoted = false + + // output + @Published public var votesCount = 0 + @Published public var isVoteButtonEnabled = true + public var isVoteButtonDisplay: Bool { + return !isMyself && !isClosed && !isPollVoted + } + + public var pollDescription: String { + var texts: [String] = [] + switch platform { + case .none: + return "" + case .twitter: + let peopleCount = votesCount + texts.append(L10n.Count.people(peopleCount)) + case .mastodon: + texts.append(L10n.Count.vote(votesCount)) + } + if isClosed { + texts.append(L10n.Common.Controls.Status.Poll.expired) + } else if let endDate = endDate { + let now = Date() + let timeInterval = endDate.timeIntervalSince(now) + if timeInterval > 0, let text = endDate.localizedTimeLeft { + texts.append(text) + } + } + return texts.joined(separator: " · ") + } + + @MainActor + public var needsUpdate: Bool { + return poll.needsUpdate + } + + public init( + authContext: AuthContext?, + poll: PollObject + ) { + self.authContext = authContext + self.poll = poll + let isMyself = { + switch authContext?.authenticationContext { + case .twitter(let authenticationContext): + guard case let .twitter(poll) = poll else { + assertionFailure() + return false + } + return authenticationContext.userID == poll.status.author.id + case .mastodon(let authenticationContext): + guard case let .mastodon(poll) = poll else { + assertionFailure() + return false + } + return authenticationContext.userID == poll.status.author.id && authenticationContext.domain == poll.status.author.domain + default: + return false + } + }() + self.isMyself = isMyself + + switch poll { + case .twitter(let poll): + platform = .twitter + options = poll.options + .sorted(by: { $0.position < $1.position }) + .map { + PollOptionView.ViewModel( + authContext: authContext, + pollOption: .twitter(object: $0), + isMyself: isMyself + ) + } + endDate = poll.endDatetime + isClosed = true // cannot vote for Twitter + case .mastodon(let poll): + platform = .mastodon + options = poll.options + .sorted(by: { $0.index < $1.index }) + .map { + PollOptionView.ViewModel( + authContext: authContext, + pollOption: .mastodon(object: $0), + isMyself: isMyself + ) + } + endDate = poll.expiresAt + poll.publisher(for: \.expired) + .assign(to: &$isClosed) + poll.publisher(for: \.isVoting) + .assign(to: &$isVoting) + if case let .mastodon(authenticationContext) = authContext?.authenticationContext { + poll.publisher(for: \.voteBy) + .map { voteBy in + voteBy.contains(where: { $0.id == authenticationContext.userID && $0.domain == authenticationContext.domain }) + } + .assign(to: &$isPollVoted) + } + } + + // collect votes into votesCount + + let votesCount = options + .map { $0.votes } + .reduce(0, +) + options + .forEach { $0.totalVotes = votesCount } + + Publishers.MergeMany(options.map { $0.$votes }) + .receive(on: DispatchQueue.main) + .compactMap { [weak self] _ in + guard let self = self else { return nil } + return self.options + .map { $0.votes } + .reduce(0, +) + } + .removeDuplicates() + .assign(to: \.votesCount, on: self) + .store(in: &disposeBag) + + // bind votesCount + $votesCount + .removeDuplicates() + .sink { [weak self] totalVotes in + guard let self = self else { return } + self.options.forEach { option in + option.totalVotes = totalVotes + } + } + .store(in: &disposeBag) + + // bind canVote + Publishers.MergeMany(options.map { $0.$isSelected }) + .receive(on: DispatchQueue.main) + .compactMap { [weak self] _ in + guard let self = self else { return nil } + return self.options + .map { $0.isSelected } + .contains(true) + } + .removeDuplicates() + .assign(to: \.isVoteButtonEnabled, on: self) + .store(in: &disposeBag) + } + } +} + diff --git a/TwidereSDK/Sources/TwidereUI/Content/PrototypeStatusView.swift b/TwidereSDK/Sources/TwidereUI/Content/PrototypeStatusView.swift new file mode 100644 index 00000000..17bb1a0c --- /dev/null +++ b/TwidereSDK/Sources/TwidereUI/Content/PrototypeStatusView.swift @@ -0,0 +1,91 @@ +// +// PrototypeStatusView.swift +// +// +// Created by MainasuK on 2022-7-25. +// + +import UIKit +import Combine + +//public protocol PrototypeStatusViewDelegate: AnyObject { +// func layoutDidUpdate(_ view: PrototypeStatusView) +//} +// +//final public class PrototypeStatusView: UIView { +// +// public var disposeBag = Set() +// private var observations = Set() +// +// weak var delegate: PrototypeStatusViewDelegate? +// +// public let statusView = StatusView() +// public private(set)var widthLayoutConstraint: NSLayoutConstraint! +// +// public override var intrinsicContentSize: CGSize { +// let size = statusView.frame.size +// defer { +// self.delegate?.layoutDidUpdate(self) +// } +// return CGSize(width: UIView.noIntrinsicMetric, height: size.height) +// } +// +// public override var frame: CGRect { +// didSet { +// guard frame != oldValue else { return } +// layoutIfNeeded() +// } +// } +// +// public override func layoutSubviews() { +// super.layoutSubviews() +// +// if frame.width != .zero { +// statusView.frame.size.width = frame.width +// widthLayoutConstraint.constant = frame.width +// widthLayoutConstraint.isActive = true +// } +// +// let targetSize = CGSize( +// width: frame.width, +// height: UIView.layoutFittingCompressedSize.height +// ) +// +// statusView.frame.size.height = statusView.systemLayoutSizeFitting( +// targetSize, +// withHorizontalFittingPriority: .required, +// verticalFittingPriority: .fittingSizeLevel +// ).height +// +// invalidateIntrinsicContentSize() +// } +// +// +// public override init(frame: CGRect) { +// super.init(frame: frame) +// _init() +// } +// +// public required init?(coder: NSCoder) { +// super.init(coder: coder) +// _init() +// } +// +//} +// +//extension PrototypeStatusView { +// private func _init() { +// widthLayoutConstraint = widthAnchor.constraint(equalToConstant: frame.width) +// +// addSubview(statusView) +// +// // trigger UIViewRepresentable size update +// statusView +// .observe(\.bounds, options: [.initial, .new]) { [weak self] statusView, _ in +// guard let self = self else { return } +// print(statusView.frame) +// self.invalidateIntrinsicContentSize() +// } +// .store(in: &observations) +// } +//} diff --git a/TwidereSDK/Sources/TwidereUI/Content/StatusHeaderView.swift b/TwidereSDK/Sources/TwidereUI/Content/StatusHeaderView.swift new file mode 100644 index 00000000..88e4318b --- /dev/null +++ b/TwidereSDK/Sources/TwidereUI/Content/StatusHeaderView.swift @@ -0,0 +1,70 @@ +// +// StatusHeaderView.swift +// +// +// Created by MainasuK on 2023/2/3. +// + +import SwiftUI +import TwidereAsset +import TwidereLocalization +import Meta +import Kingfisher + +public struct StatusHeaderView: View { + + static var iconImageTrailingSpacing: CGFloat { 4.0 } + + @ObservedObject public var viewModel: ViewModel + + @ScaledMetric(relativeTo: .footnote) private var iconImageDimension: CGFloat = 16 + + public var body: some View { + HStack(spacing: .zero) { + if viewModel.hasHangingAvatar { + let width = viewModel.avatarDimension + + StatusView.hangingAvatarButtonTrailingSpacing + - iconImageDimension + - StatusHeaderView.iconImageTrailingSpacing + Color.clear + .frame(width: max(.leastNonzeroMagnitude, width)) + } // end if + HStack(spacing: StatusHeaderView.iconImageTrailingSpacing) { + VectorImageView(image: viewModel.image) + .frame(width: iconImageDimension, height: iconImageDimension) + .offset(y: -1) + LabelRepresentable( + metaContent: viewModel.label, + textStyle: .statusHeader, + setupLabel: { label in + // do nothing + } + ) + .fixedSize(horizontal: false, vertical: true) + Spacer() + } // HStack + } // HStack + .onTapGesture { + // TODO: + } + } // end body + +} + +extension StatusHeaderView { + public class ViewModel: ObservableObject { + @Published public var image: UIImage + @Published public var label: MetaContent + + @Published public var hasHangingAvatar: Bool = false + @Published public var avatarDimension: CGFloat = StatusView.hangingAvatarButtonDimension + + public init( + image: UIImage, + label: MetaContent + ) { + self.image = image + self.label = label + } + } +} diff --git a/TwidereSDK/Sources/TwidereUI/Content/StatusMetricView.swift b/TwidereSDK/Sources/TwidereUI/Content/StatusMetricView.swift new file mode 100644 index 00000000..22686e0f --- /dev/null +++ b/TwidereSDK/Sources/TwidereUI/Content/StatusMetricView.swift @@ -0,0 +1,209 @@ +// +// StatusMetricView.swift +// +// +// Created by MainasuK on 2023/3/27. +// + +import os.log +import SwiftUI +import CoreDataStack + +public struct StatusMetricView: View { + + static let logger = Logger(subsystem: "StatusMetricView", category: "View") + var logger: Logger { StatusView.logger } + + @ObservedObject public var viewModel: ViewModel + public let handler: (Action) -> Void + + public var body: some View { + VStack { + HStack { + Text(viewModel.timestampText) + .font(Font(TextStyle.statusMetrics.font)) + .foregroundColor(Color(uiColor: TextStyle.statusMetrics.textColor)) + } + HStack(spacing: 16) { + Spacer() + replyButton + repostButton + switch viewModel.platform { + case .twitter: + quoteButton + case .mastodon: + EmptyView() + case .none: + EmptyView() + } + likeButton + Spacer() + } + } + } +} + +extension StatusMetricView { + public var replyButton: some View { + ToolbarButton( + handler: { action in + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): reply") + handler(action) + }, + action: .reply, + image: Asset.Arrows.arrowTurnUpLeftMini.image.withRenderingMode(.alwaysTemplate), + count: viewModel.replyCount, + tintColor: nil + ) + } + + public var repostButton: some View { + ToolbarButton( + handler: { action in + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): repost") + handler(action) + }, + action: .repost, + image: Asset.Media.repeatMini.image.withRenderingMode(.alwaysTemplate), + count: viewModel.repostCount, + tintColor: nil + ) + } + + public var quoteButton: some View { + ToolbarButton( + handler: { action in + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): quote") + handler(action) + }, + action: .quote, + image: Asset.TextFormatting.textQuoteMini.image.withRenderingMode(.alwaysTemplate), + count: viewModel.quoteCount, + tintColor: nil + ) + } + + public var likeButton: some View { + ToolbarButton( + handler: { action in + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): like") + handler(action) + }, + action: .like, + image: Asset.Health.heartMini.image.withRenderingMode(.alwaysTemplate), + count: viewModel.likeCount, + tintColor: nil + ) + } +} + +extension StatusMetricView { + public enum Action: Hashable, CaseIterable { + case reply + case repost + case quote + case like + + public var text: String { + switch self { + case .reply: return L10n.Common.Controls.Status.Actions.reply + case .repost: return L10n.Common.Controls.Status.Actions.repost + case .quote: return L10n.Common.Controls.Status.Actions.quote + case .like: return L10n.Common.Controls.Status.Actions.like + } + } + + public var icon: UIImage { + switch self { + case .reply: return Asset.Arrows.arrowTurnUpLeft.image + case .repost: return Asset.Media.repeat.image + case .quote: return Asset.TextFormatting.textQuote.image + case .like: return Asset.Health.heartFill.image + } + } + } +} + +extension StatusMetricView { + public struct ToolbarButton: View { + let handler: (Action) -> Void + let action: Action + let image: UIImage + let count: Int? + let tintColor: UIColor? + + // output + var text: String { + Self.metric(count: count) + } + + public init( + handler: @escaping (Action) -> Void, + action: Action, + image: UIImage, + count: Int?, + tintColor: UIColor? + ) { + self.handler = handler + self.action = action + self.image = image + self.count = count + self.tintColor = tintColor + } + + public var body: some View { + Button { + handler(action) + } label: { + HStack { + Image(uiImage: image) + Text(text) + .font(Font(TextStyle.statusMetrics.font)) + .lineLimit(1) + } + } + .buttonStyle(.borderless) + .tint(Color(uiColor: tintColor ?? .secondaryLabel)) + .foregroundColor(Color(uiColor: tintColor ?? .secondaryLabel)) + } + + static func metric(count: Int?) -> String { + guard let count = count, count > 0 else { + return "0" + } + return "\(count)" + } + } +} + +extension StatusMetricView { + public class ViewModel: ObservableObject { + // input + public let platform: Platform + public let timestamp: Date + + @Published public var source: String? + @Published public var replyCount: Int = 0 + @Published public var repostCount: Int = 0 + @Published public var quoteCount: Int = 0 + @Published public var likeCount: Int = 0 + + // output + public let timestampText: String + + public init( + platform: Platform, + timestamp: Date + ) { + self.platform = platform + self.timestamp = timestamp + self.timestampText = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .medium + let text = formatter.string(from: timestamp) + return text + }() + } + } +} diff --git a/TwidereSDK/Sources/TwidereUI/Content/StatusMetricsDashboardView.swift b/TwidereSDK/Sources/TwidereUI/Content/StatusMetricsDashboardView.swift index 6bbb4066..3b45d018 100644 --- a/TwidereSDK/Sources/TwidereUI/Content/StatusMetricsDashboardView.swift +++ b/TwidereSDK/Sources/TwidereUI/Content/StatusMetricsDashboardView.swift @@ -8,7 +8,6 @@ import os.log import UIKit import CoreDataStack -import TwidereCommon import TwidereCore public protocol StatusMetricsDashboardViewDelegate: AnyObject { diff --git a/TwidereSDK/Sources/TwidereUI/Content/StatusToolbarView.swift b/TwidereSDK/Sources/TwidereUI/Content/StatusToolbarView.swift new file mode 100644 index 00000000..17992bac --- /dev/null +++ b/TwidereSDK/Sources/TwidereUI/Content/StatusToolbarView.swift @@ -0,0 +1,392 @@ +// +// StatusToolbarView.swift +// +// +// Created by MainasuK on 2023/3/14. +// + +import os.log +import SwiftUI +import CoreDataStack + +public struct StatusToolbarView: View { + + static let logger = Logger(subsystem: "StatusToolbarView", category: "View") + var logger: Logger { StatusView.logger } + + @ObservedObject public var viewModel: ViewModel + public var menuActions: [Action] + public let handler: (Action) -> Void + + public var body: some View { + HStack { + replyButton + Group { + switch viewModel.platform { + case .twitter: + repostMenu + case .mastodon: + repostButton + case .none: + repostButton + } + } + likeButton + shareMenu + .background( + WrapperViewRepresentable(view: viewModel.menuButtonBackgroundView) + ) + } // end HStack + } // end body + +} + +extension StatusToolbarView { + var isMetricCountDisplay: Bool { + switch viewModel.style { + case .inline: return true + case .plain: return false + } + } + + var isExtraSpacerDisplay: Bool { + switch viewModel.style { + case .inline: return true + case .plain: return false + } + } +} + +extension StatusToolbarView { + public var replyButton: some View { + ToolbarButton( + handler: { action in + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): reply") + handler(action) + }, + action: .reply, + image: { + switch viewModel.style { + case .inline: + return Asset.Communication.textBubbleMini.image.withRenderingMode(.alwaysTemplate) + case .plain: + return Asset.Communication.textBubble.image.withRenderingMode(.alwaysTemplate) + } + }(), + count: isMetricCountDisplay ? viewModel.replyCount : nil, + tintColor: nil + ) + } + + enum RepostButtonImage { + case repost + case repostOff + case repostLock + + func image(style: StatusToolbarView.Style) -> UIImage { + switch self { + case .repost: + switch style { + case .inline: return Asset.Media.repeatMini.image.withRenderingMode(.alwaysTemplate) + case .plain: return Asset.Media.repeat.image.withRenderingMode(.alwaysTemplate) + } + case .repostOff: + switch style { + case .inline: return Asset.Media.repeatOffMini.image.withRenderingMode(.alwaysTemplate) + case .plain: return Asset.Media.repeatOff.image.withRenderingMode(.alwaysTemplate) + } + case .repostLock: + switch style { + case .inline: return Asset.Media.repeatLockMini.image.withRenderingMode(.alwaysTemplate) + case .plain: return Asset.Media.repeatLock.image.withRenderingMode(.alwaysTemplate) + } + } // end switch + } // end func + + static func kind( + platform: Platform, + isReposeRestricted: Bool, + isMyself: Bool + ) -> Self { + switch platform { + case .twitter: + if isMyself { return .repost } + if isReposeRestricted { return .repostOff } + return .repost + case .mastodon: + if isReposeRestricted { + return isMyself ? .repostLock : .repostOff + } + return .repost + case .none: + return .repost + } // end switch + } + } + + public var repostButton: some View { + ToolbarButton( + handler: { action in + guard viewModel.isRepostable else { return } + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): repost") + handler(action) + }, + action: .repost, + image: { + return RepostButtonImage.kind( + platform: viewModel.platform, + isReposeRestricted: viewModel.isReposeRestricted, + isMyself: viewModel.isMyself + ).image(style: viewModel.style) + }(), + count: isMetricCountDisplay ? viewModel.repostCount : nil, + tintColor: viewModel.isReposted ? Asset.Scene.Status.Toolbar.repost.color : nil + ) + .opacity(viewModel.isRepostable ? 1 : 0.5) + } + + public var repostMenu: some View { + Menu { + if viewModel.isRepostable { + // repost + Button { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): repost") + handler(.repost) + } label: { + Label { + let text = viewModel.isReposted ? L10n.Common.Controls.Status.Actions.undoRetweet : L10n.Common.Controls.Status.Actions.retweet + Text(text) + } icon: { + let image = viewModel.isReposted ? Asset.Media.repeatOff.image.withRenderingMode(.alwaysTemplate) : Asset.Media.repeat.image.withRenderingMode(.alwaysTemplate) + Image(uiImage: image) + } + } + // quote + Button { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): quote") + handler(.quote) + } label: { + Label { + Text(L10n.Common.Controls.Status.Actions.quote) + } icon: { + Image(uiImage: Asset.TextFormatting.textQuote.image.withRenderingMode(.alwaysTemplate)) + } + } + } + } label: { + repostButton + } + } + + public var likeButton: some View { + ToolbarButton( + handler: { action in + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): like") + handler(action) + }, + action: .like, + image: { + switch viewModel.style { + case .inline: + return viewModel.isLiked ? Asset.Health.heartFillMini.image.withRenderingMode(.alwaysTemplate) : Asset.Health.heartMini.image.withRenderingMode(.alwaysTemplate) + case .plain: + return viewModel.isLiked ? Asset.Health.heartFill.image.withRenderingMode(.alwaysTemplate) : Asset.Health.heart.image.withRenderingMode(.alwaysTemplate) + } + + }(), + count: isMetricCountDisplay ? viewModel.likeCount : nil, + tintColor: viewModel.isLiked ? Asset.Scene.Status.Toolbar.like.color : nil + ) + } + + public var shareMenu: some View { + Menu { + ForEach(menuActions, id: \.self) { action in + Button(role: action.isDestructive ? .destructive : nil) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(String(describing: action))") + handler(action) + } label: { + Label { + Text(action.text) + } icon: { + Image(uiImage: action.icon) + } + } // end Button + } // end ForEach + } label: { + HStack { + let image: UIImage = { + switch viewModel.style { + case .inline: + return Asset.Editing.ellipsisMini.image.withRenderingMode(.alwaysTemplate) + case .plain: + return Asset.Editing.ellipsis.image.withRenderingMode(.alwaysTemplate) + } + }() + Image(uiImage: image) + .foregroundColor(.secondary) + } + } + .buttonStyle(.borderless) + .modifier(MaxWidthModifier(max: nil)) + } +} + +extension StatusToolbarView { + public class ViewModel: ObservableObject { + public let menuButtonBackgroundView = UIView() + + // input + @Published var platform: Platform = .none + @Published var style: Style = .inline + + @Published var replyCount: Int? + @Published var repostCount: Int? + @Published var likeCount: Int? + + @Published var isReposted: Bool = false + @Published var isLiked: Bool = false + + @Published var isReposeRestricted: Bool = false + @Published var isMyself: Bool = false + + var isRepostable: Bool { + return isMyself || !isReposeRestricted + } + + public init() { + // end init + } + } +} + +extension StatusToolbarView { + public enum Style: Hashable { + case inline + case plain + } + + public enum Action: Hashable, CaseIterable { + case reply + case repost + case quote + case like + case copyText + case copyLink + case shareLink + case saveMedia + case translate + case delete + + public var text: String { + switch self { + case .reply: return L10n.Common.Controls.Status.Actions.reply + case .repost: return L10n.Common.Controls.Status.Actions.repost + case .quote: return L10n.Common.Controls.Status.Actions.quote + case .like: return L10n.Common.Controls.Status.Actions.like + case .copyText: return L10n.Common.Controls.Status.Actions.copyText + case .copyLink: return L10n.Common.Controls.Status.Actions.copyLink + case .shareLink: return L10n.Common.Controls.Status.Actions.shareLink + case .saveMedia: return L10n.Common.Controls.Status.Actions.saveMedia + case .translate: return L10n.Common.Controls.Status.Actions.translate + case .delete: return L10n.Common.Controls.Actions.delete + } + } + + public var icon: UIImage { + switch self { + case .reply: return Asset.Arrows.arrowTurnUpLeft.image + case .repost: return Asset.Media.repeat.image + case .quote: return Asset.TextFormatting.textQuote.image + case .like: return Asset.Health.heartFill.image + case .copyText: return UIImage(systemName: "doc.on.doc")! + case .copyLink: return UIImage(systemName: "link")! + case .shareLink: return UIImage(systemName: "square.and.arrow.up")! + case .saveMedia: return UIImage(systemName: "square.and.arrow.down")! + case .translate: return UIImage(systemName: "character.bubble")! + case .delete: return UIImage(systemName: "minus.circle")! + } + } + + public var isDestructive: Bool { + switch self { + case .delete: return true + default: return false + } + } + } +} + +extension StatusToolbarView { + public struct ToolbarButton: View { + static let numberMetricFormatter = NumberMetricFormatter() + + let handler: (Action) -> Void + let action: Action + let image: UIImage + let count: Int? + let tintColor: UIColor? + + // output + let text: String + + public init( + handler: @escaping (Action) -> Void, + action: Action, + image: UIImage, + count: Int?, + tintColor: UIColor? + ) { + self.handler = handler + self.action = action + self.image = image + self.count = count + self.tintColor = tintColor + self.text = Self.metric(count: count) + } + + public var body: some View { + Button { + handler(action) + } label: { + HStack { + Image(uiImage: image) + Text(text) + .font(.system(size: 12, weight: .medium)) + .lineLimit(1) + Spacer() + } + } + .buttonStyle(.borderless) + .tint(Color(uiColor: tintColor ?? .secondaryLabel)) + .foregroundColor(Color(uiColor: tintColor ?? .secondaryLabel)) + } + + static func metric(count: Int?) -> String { + guard let count = count, count > 0 else { + return "" + } + return ToolbarButton.numberMetricFormatter.string(from: count) ?? "" + } + } +} + +extension StatusToolbarView { + public struct MaxWidthModifier: ViewModifier { + let max: CGFloat? + + public init(max: CGFloat?) { + self.max = max + } + + @ViewBuilder + public func body(content: Content) -> some View { + if let max = max { + content + .frame(maxWidth: max) + } else { + content + } + } + } +} diff --git a/TwidereSDK/Sources/TwidereUI/Content/StatusView+Configuration.swift b/TwidereSDK/Sources/TwidereUI/Content/StatusView+Configuration.swift index ef0aced8..e2e2a690 100644 --- a/TwidereSDK/Sources/TwidereUI/Content/StatusView+Configuration.swift +++ b/TwidereSDK/Sources/TwidereUI/Content/StatusView+Configuration.swift @@ -1,6 +1,6 @@ // // StatusView+Configuration.swift -// +// // // Created by MainasuK on 2022-6-10. // @@ -11,616 +11,615 @@ import Combine import SwiftUI import CoreData import CoreDataStack -import TwidereCommon import TwidereCore import TwitterMeta import MastodonMeta import Meta -extension StatusView { - public struct ConfigurationContext { - public let dateTimeProvider: DateTimeProvider - public let twitterTextProvider: TwitterTextProvider - public let authenticationContext: Published.Publisher - - public init( - dateTimeProvider: DateTimeProvider, - twitterTextProvider: TwitterTextProvider, - authenticationContext: Published.Publisher - ) { - self.dateTimeProvider = dateTimeProvider - self.twitterTextProvider = twitterTextProvider - self.authenticationContext = authenticationContext - } - } -} - -extension StatusView { - public func configure( - feed: Feed, - configurationContext: ConfigurationContext - ) { - switch feed.content { - case .none: - logger.log(level: .info, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Warning] feed content missing") - case .twitter(let status): - configure( - status: status, - configurationContext: configurationContext - ) - case .mastodon(let status): - configure( - status: status, - notification: nil, - configurationContext: configurationContext - ) - case .mastodonNotification(let notification): - guard let status = notification.status else { - assertionFailure() - return - } - configure( - status: status, - notification: notification, - configurationContext: configurationContext - ) - } - } - - public func configure( - statusObject object: StatusObject, - configurationContext: ConfigurationContext - ) { - switch object { - case .twitter(let status): - configure( - status: status, - configurationContext: configurationContext - ) - case .mastodon(let status): - configure( - status: status, - notification: nil, - configurationContext: configurationContext - ) - } - } - -} - -// MARK: - Twitter - -extension StatusView { - public func configure( - status: TwitterStatus, - configurationContext: ConfigurationContext - ) { - viewModel.prepareForReuse() - - viewModel.managedObjectContext = status.managedObjectContext - viewModel.objects.insert(status) - - viewModel.platform = .twitter - viewModel.dateTimeProvider = configurationContext.dateTimeProvider - viewModel.twitterTextProvider = configurationContext.twitterTextProvider - configurationContext.authenticationContext.assign(to: \.authenticationContext, on: viewModel).store(in: &disposeBag) - - configureHeader(status) - configureAuthor(status) - configureContent(status) - configureMedia(status) - configurePoll(status) - configureLocation(status) - configureToolbar(status) - configureReplySettings(status) - - if let quote = status.quote ?? status.repost?.quote { - quoteStatusView?.configure( - status: quote, - configurationContext: configurationContext - ) - setQuoteDisplay() - } - } - - private func configureHeader(_ status: TwitterStatus) { - if let _ = status.repost { - status.author.publisher(for: \.name) - .map { name -> StatusView.ViewModel.Header in - let userRepostText = L10n.Common.Controls.Status.userRetweeted(name) - let metaContent = PlaintextMetaContent(string: userRepostText) - let info = ViewModel.Header.RepostInfo(authorNameMetaContent: metaContent) - return .repost(info: info) - } - .assign(to: \.header, on: viewModel) - .store(in: &disposeBag) - } else { - viewModel.header = .none - } - } - - private func configureAuthor(_ status: TwitterStatus) { - guard let dateTimeProvider = viewModel.dateTimeProvider else { - assertionFailure() - return - } - - let author = (status.repost ?? status).author - - viewModel.userIdentifier = .twitter(.init(id: author.id)) - - // author avatar - author.publisher(for: \.profileImageURL) - .map { _ in author.avatarImageURL() } - .assign(to: \.authorAvatarImageURL, on: viewModel) - .store(in: &disposeBag) - // lock - author.publisher(for: \.protected) - .assign(to: \.protected, on: viewModel) - .store(in: &disposeBag) - // author name - author.publisher(for: \.name) - .map { PlaintextMetaContent(string: $0) } - .assign(to: \.authorName, on: viewModel) - .store(in: &disposeBag) - // author username - author.publisher(for: \.username) - .map { $0 as String? } - .assign(to: \.authorUsername, on: viewModel) - .store(in: &disposeBag) - // timestamp - viewModel.dateTimeProvider = dateTimeProvider - (status.repost ?? status).publisher(for: \.createdAt) - .map { $0 as Date? } - .assign(to: \.timestamp, on: viewModel) - .store(in: &disposeBag) - } - - private func configureContent(_ status: TwitterStatus) { - guard let twitterTextProvider = viewModel.twitterTextProvider else { - assertionFailure() - return - } - - let status = status.repost ?? status - let content = TwitterContent(content: status.displayText) - let metaContent = TwitterMetaContent.convert( - content: content, - urlMaximumLength: 20, - twitterTextProvider: twitterTextProvider - ) - viewModel.spoilerContent = nil - viewModel.isContentReveal = true - viewModel.isContentSensitive = false - viewModel.isContentSensitiveToggled = false - viewModel.content = metaContent - viewModel.sharePlaintextContent = status.displayText - viewModel.language = status.language - viewModel.source = status.source - } - - private func configureMedia(_ status: TwitterStatus) { - let status = status.repost ?? status - - mediaGridContainerView.viewModel.resetContentWarningOverlay() - viewModel.isMediaSensitive = false - viewModel.isMediaSensitiveToggled = false - viewModel.isMediaSensitiveSwitchable = false - viewModel.mediaViewConfigurations = MediaView.configuration(twitterStatus: status) - } - - private func configurePoll(_ status: TwitterStatus) { - let status = status.repost ?? status - - // pollItems - status.publisher(for: \.poll) - .sink { [weak self] poll in - guard let self = self else { return } - guard let poll = poll else { - self.viewModel.pollItems = [] - return - } - - let options = poll.options.sorted(by: { $0.position < $1.position }) - let items: [PollItem] = options.map { .option(record: .twitter(record: .init(objectID: $0.objectID))) } - self.viewModel.pollItems = items - } - .store(in: &disposeBag) - // isVoteButtonEnabled - viewModel.isVoteButtonEnabled = false - // isVotable - viewModel.isVotable = false - // votesCount - if let poll = status.poll { - poll.publisher(for: \.updatedAt) - .map { _ in poll.options.map { Int($0.votes) }.reduce(0, +) } - .assign(to: \.voteCount, on: viewModel) - .store(in: &disposeBag) - } - // voterCount - // none - // expireAt - viewModel.expireAt = status.poll?.endDatetime - // expired - viewModel.expired = status.poll?.votingStatus == .closed - // isVoting - viewModel.isVoting = false - } - - private func configureLocation(_ status: TwitterStatus) { - let status = status.repost ?? status - status.publisher(for: \.location) - .map { $0?.fullName } - .assign(to: \.location, on: viewModel) - .store(in: &disposeBag) - } - - private func configureToolbar(_ status: TwitterStatus) { - let status = status.repost ?? status - - status.publisher(for: \.replyCount) - .map(Int.init) - .assign(to: \.replyCount, on: viewModel) - .store(in: &disposeBag) - status.publisher(for: \.repostCount) - .map(Int.init) - .assign(to: \.repostCount, on: viewModel) - .store(in: &disposeBag) - status.publisher(for: \.quoteCount) - .map(Int.init) - .assign(to: \.quoteCount, on: viewModel) - .store(in: &disposeBag) - status.publisher(for: \.likeCount) - .map(Int.init) - .assign(to: \.likeCount, on: viewModel) - .store(in: &disposeBag) - viewModel.shareStatusURL = status.statusURL.absoluteString - - // relationship - Publishers.CombineLatest( - viewModel.$authenticationContext, - status.publisher(for: \.repostBy) - ) - .map { authenticationContext, repostBy in - guard let authenticationContext = authenticationContext?.twitterAuthenticationContext else { - return false - } - let userID = authenticationContext.userID - return repostBy.contains(where: { $0.id == userID }) - } - .assign(to: \.isRepost, on: viewModel) - .store(in: &disposeBag) - - Publishers.CombineLatest( - viewModel.$authenticationContext, - status.publisher(for: \.likeBy) - ) - .map { authenticationContext, likeBy in - guard let authenticationContext = authenticationContext?.twitterAuthenticationContext else { - return false - } - let userID = authenticationContext.userID - return likeBy.contains(where: { $0.id == userID }) - } - .assign(to: \.isLike, on: viewModel) - .store(in: &disposeBag) - - let authorUserID = status.author.id - viewModel.$authenticationContext - .map { authenticationContext in - guard let authenticationContext = authenticationContext?.twitterAuthenticationContext else { - return false - } - return authenticationContext.userID == authorUserID - } - .assign(to: \.isDeletable, on: viewModel) - .store(in: &disposeBag) - } - - func configureReplySettings(_ status: TwitterStatus) { - let status = status.repost ?? status - - viewModel.replySettings = status.replySettings?.typed - } - -} - -// MARK: - Mastodon - -extension StatusView { - public func configure( - status: MastodonStatus, - notification: MastodonNotification?, - configurationContext: ConfigurationContext - ) { - viewModel.prepareForReuse() - - viewModel.managedObjectContext = status.managedObjectContext - viewModel.objects.insert(status) - - viewModel.platform = .mastodon - viewModel.dateTimeProvider = configurationContext.dateTimeProvider - viewModel.twitterTextProvider = configurationContext.twitterTextProvider - configurationContext.authenticationContext.assign(to: \.authenticationContext, on: viewModel).store(in: &disposeBag) +//extension StatusView { +// public struct ConfigurationContext { +// public let dateTimeProvider: DateTimeProvider +// public let twitterTextProvider: TwitterTextProvider +// public let viewLayoutFramePublisher: Published.Publisher? +// +// public init( +// dateTimeProvider: DateTimeProvider, +// twitterTextProvider: TwitterTextProvider, +// viewLayoutFramePublisher: Published.Publisher? +// ) { +// self.dateTimeProvider = dateTimeProvider +// self.twitterTextProvider = twitterTextProvider +// self.viewLayoutFramePublisher = viewLayoutFramePublisher +// } +// } +//} - configureHeader(status, notification: notification) - configureAuthor(status) - configureContent(status) - configureMedia(status) - configurePoll(status) - configureToolbar(status) - } - - private func configureHeader( - _ status: MastodonStatus, - notification: MastodonNotification? - ) { - if let notification = notification { - let user = notification.account - let type = notification.notificationType - Publishers.CombineLatest( - user.publisher(for: \.displayName), - user.publisher(for: \.emojis) - ) - .map { _ in - guard let info = NotificationHeaderInfo(type: type, user: user) else { return .none } - return ViewModel.Header.notification(info: info) - } - .assign(to: \.header, on: viewModel) - .store(in: &disposeBag) - } else if let _ = status.repost { - Publishers.CombineLatest( - status.author.publisher(for: \.displayName), - status.author.publisher(for: \.emojis) - ) - .map { _, emojis -> StatusView.ViewModel.Header in - let name = status.author.name - let userRepostText = L10n.Common.Controls.Status.userBoosted(name) - let content = MastodonContent(content: userRepostText, emojis: emojis.asDictionary) - do { - let metaContent = try MastodonMetaContent.convert(document: content) - let info = ViewModel.Header.RepostInfo(authorNameMetaContent: metaContent) - return .repost(info: info) - } catch { - assertionFailure(error.localizedDescription) - let metaContent = PlaintextMetaContent(string: userRepostText) - let info = ViewModel.Header.RepostInfo(authorNameMetaContent: metaContent) - return .repost(info: info) - } - } - .assign(to: \.header, on: viewModel) - .store(in: &disposeBag) - } else { - viewModel.header = .none - } - } - - private func configureAuthor(_ status: MastodonStatus) { - let author = (status.repost ?? status).author - - viewModel.userIdentifier = .mastodon(.init(domain: author.domain, id: author.id)) - - // author avatar - author.publisher(for: \.avatar) - .map { url in url.flatMap { URL(string: $0) } } - .assign(to: \.authorAvatarImageURL, on: viewModel) - .store(in: &disposeBag) - // author name - Publishers.CombineLatest( - author.publisher(for: \.displayName), - author.publisher(for: \.emojis) - ) - .map { _, emojis in - do { - let content = MastodonContent(content: author.name, emojis: emojis.asDictionary) - let metaContent = try MastodonMetaContent.convert(document: content) - return metaContent - } catch { - assertionFailure(error.localizedDescription) - return PlaintextMetaContent(string: author.name) - } - } - .assign(to: \.authorName, on: viewModel) - .store(in: &disposeBag) - // author username - author.publisher(for: \.acct) - .map { $0 as String? } - .assign(to: \.authorUsername, on: viewModel) - .store(in: &disposeBag) - // protected - author.publisher(for: \.locked) - .assign(to: \.protected, on: viewModel) - .store(in: &disposeBag) - // visibility - viewModel.visibility = status.visibility.asStatusVisibility - // timestamp - (status.repost ?? status).publisher(for: \.createdAt) - .map { $0 as Date? } - .assign(to: \.timestamp, on: viewModel) - .store(in: &disposeBag) - } - - private func configureContent(_ status: MastodonStatus) { - let status = status.repost ?? status - do { - let content = MastodonContent(content: status.content, emojis: status.emojis.asDictionary) - let metaContent = try MastodonMetaContent.convert(document: content) - viewModel.content = metaContent - viewModel.sharePlaintextContent = metaContent.original - } catch { - assertionFailure(error.localizedDescription) - viewModel.content = PlaintextMetaContent(string: "") - } - - if let spoilerText = status.spoilerText, !spoilerText.isEmpty { - do { - let content = MastodonContent(content: spoilerText, emojis: status.emojis.asDictionary) - let metaContent = try MastodonMetaContent.convert(document: content) - viewModel.spoilerContent = metaContent - } catch { - assertionFailure() - viewModel.spoilerContent = nil - } - } else { - viewModel.spoilerContent = nil - } - - viewModel.isContentSensitiveToggled = status.isContentSensitiveToggled - status.publisher(for: \.isContentSensitiveToggled) - .assign(to: \.isContentSensitiveToggled, on: viewModel) - .store(in: &disposeBag) - - viewModel.language = status.language - viewModel.source = status.source - } - - private func configureMedia(_ status: MastodonStatus) { - let status = status.repost ?? status - - mediaGridContainerView.viewModel.resetContentWarningOverlay() - viewModel.isMediaSensitiveSwitchable = true - viewModel.mediaViewConfigurations = MediaView.configuration(mastodonStatus: status) - - // set directly without delay - viewModel.isMediaSensitiveToggled = status.isMediaSensitiveToggled - viewModel.isMediaSensitive = status.isMediaSensitive - mediaGridContainerView.configureOverlayDisplay( - isDisplay: status.isMediaSensitiveToggled ? !status.isMediaSensitive : !status.isMediaSensitive, - animated: false - ) - - status.publisher(for: \.isMediaSensitiveToggled) - .receive(on: DispatchQueue.main) - .assign(to: \.isMediaSensitiveToggled, on: viewModel) - .store(in: &disposeBag) - } - - private func configurePoll(_ status: MastodonStatus) { - let status = status.repost ?? status - - // pollItems - status.publisher(for: \.poll) - .sink { [weak self] poll in - guard let self = self else { return } - guard let poll = poll else { - self.viewModel.pollItems = [] - return - } - - let options = poll.options.sorted(by: { $0.index < $1.index }) - let items: [PollItem] = options.map { .option(record: .mastodon(record: .init(objectID: $0.objectID))) } - self.viewModel.pollItems = items - } - .store(in: &disposeBag) - // isVoteButtonEnabled - status.poll?.publisher(for: \.updatedAt) - .sink { [weak self] _ in - guard let self = self else { return } - guard let poll = status.poll else { return } - let options = poll.options - let hasSelectedOption = options.contains(where: { $0.isSelected }) - self.viewModel.isVoteButtonEnabled = hasSelectedOption - } - .store(in: &disposeBag) - // isVotable - if let poll = status.poll { - Publishers.CombineLatest3( - poll.publisher(for: \.voteBy), - poll.publisher(for: \.expired), - viewModel.$authenticationContext - ) - .map { voteBy, expired, authenticationContext in - guard case let .mastodon(authenticationContext) = authenticationContext else { return false } - let domain = authenticationContext.domain - let userID = authenticationContext.userID - let isVoted = voteBy.contains(where: { $0.domain == domain && $0.id == userID }) - return !isVoted && !expired - } - .assign(to: &viewModel.$isVotable) - } - // votesCount - status.poll?.publisher(for: \.votesCount) - .map { Int($0) } - .assign(to: \.voteCount, on: viewModel) - .store(in: &disposeBag) - // voterCount - status.poll?.publisher(for: \.votersCount) - .map { Int($0) } - .assign(to: \.voterCount, on: viewModel) - .store(in: &disposeBag) - // expireAt - status.poll?.publisher(for: \.expiresAt) - .assign(to: \.expireAt, on: viewModel) - .store(in: &disposeBag) - // expired - status.poll?.publisher(for: \.expired) - .assign(to: \.expired, on: viewModel) - .store(in: &disposeBag) - // isVoting - status.poll?.publisher(for: \.isVoting) - .assign(to: \.isVoting, on: viewModel) - .store(in: &disposeBag) - } - - private func configureToolbar(_ status: MastodonStatus) { - let status = status.repost ?? status - - viewModel.quoteCount = 0 - status.publisher(for: \.replyCount) - .map(Int.init) - .assign(to: \.replyCount, on: viewModel) - .store(in: &disposeBag) - status.publisher(for: \.repostCount) - .map(Int.init) - .assign(to: \.repostCount, on: viewModel) - .store(in: &disposeBag) - status.publisher(for: \.likeCount) - .map(Int.init) - .assign(to: \.likeCount, on: viewModel) - .store(in: &disposeBag) - viewModel.shareStatusURL = status.url ?? status.uri - - // relationship - Publishers.CombineLatest( - viewModel.$authenticationContext, - status.publisher(for: \.repostBy) - ) - .map { authenticationContext, repostBy in - guard let authenticationContext = authenticationContext?.mastodonAuthenticationContext else { - return false - } - let domain = authenticationContext.domain - let userID = authenticationContext.userID - return repostBy.contains(where: { $0.id == userID && $0.domain == domain }) - } - .assign(to: \.isRepost, on: viewModel) - .store(in: &disposeBag) - - Publishers.CombineLatest( - viewModel.$authenticationContext, - status.publisher(for: \.likeBy) - ) - .map { authenticationContext, likeBy in - guard let authenticationContext = authenticationContext?.mastodonAuthenticationContext else { - return false - } - let domain = authenticationContext.domain - let userID = authenticationContext.userID - return likeBy.contains(where: { $0.id == userID && $0.domain == domain }) - } - .assign(to: \.isLike, on: viewModel) - .store(in: &disposeBag) - - let authorUserID = status.author.id - viewModel.$authenticationContext - .map { authenticationContext in - guard let authenticationContext = authenticationContext?.mastodonAuthenticationContext else { - return false - } - return authenticationContext.userID == authorUserID - } - .assign(to: \.isDeletable, on: viewModel) - .store(in: &disposeBag) - } - -} +//extension StatusView { +// public func configure( +// feed: Feed, +// configurationContext: ConfigurationContext +// ) { +// switch feed.content { +// case .none: +// logger.log(level: .info, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Warning] feed content missing") +// case .twitter(let status): +// configure( +// status: status, +// configurationContext: configurationContext +// ) +// case .mastodon(let status): +// configure( +// status: status, +// notification: nil, +// configurationContext: configurationContext +// ) +// case .mastodonNotification(let notification): +// guard let status = notification.status else { +// assertionFailure() +// return +// } +// configure( +// status: status, +// notification: notification, +// configurationContext: configurationContext +// ) +// } +// } +// +// public func configure( +// statusObject object: StatusObject, +// configurationContext: ConfigurationContext +// ) { +// switch object { +// case .twitter(let status): +// configure( +// status: status, +// configurationContext: configurationContext +// ) +// case .mastodon(let status): +// configure( +// status: status, +// notification: nil, +// configurationContext: configurationContext +// ) +// } +// } +// +//} +// +//// MARK: - Twitter +// +//extension StatusView { +// public func configure( +// status: TwitterStatus, +// configurationContext: ConfigurationContext +// ) { +// viewModel.prepareForReuse() +// +// viewModel.authenticationContext = configurationContext.authContext.authenticationContext +// viewModel.managedObjectContext = status.managedObjectContext +// viewModel.objects.insert(status) +// +// viewModel.platform = .twitter +// viewModel.dateTimeProvider = configurationContext.dateTimeProvider +// viewModel.twitterTextProvider = configurationContext.twitterTextProvider +// +// configureHeader(status) +// configureAuthor(status) +// configureContent(status) +// configureMedia(status) +// configurePoll(status) +// configureLocation(status) +// configureToolbar(status) +// configureReplySettings(status) +// +// if let quote = status.quote ?? status.repost?.quote { +// quoteStatusView?.configure( +// status: quote, +// configurationContext: configurationContext +// ) +// setQuoteDisplay() +// } +// } +// +// private func configureHeader(_ status: TwitterStatus) { +// if let _ = status.repost { +// status.author.publisher(for: \.name) +// .map { name -> StatusView.ViewModel.Header in +// let userRepostText = L10n.Common.Controls.Status.userRetweeted(name) +// let metaContent = PlaintextMetaContent(string: userRepostText) +// let info = ViewModel.Header.RepostInfo(authorNameMetaContent: metaContent) +// return .repost(info: info) +// } +// .assign(to: \.header, on: viewModel) +// .store(in: &disposeBag) +// } else { +// viewModel.header = .none +// } +// } +// +// private func configureAuthor(_ status: TwitterStatus) { +// guard let dateTimeProvider = viewModel.dateTimeProvider else { +// assertionFailure() +// return +// } +// +// let author = (status.repost ?? status).author +// +// viewModel.userIdentifier = .twitter(.init(id: author.id)) +// +// // author avatar +// author.publisher(for: \.profileImageURL) +// .map { _ in author.avatarImageURL() } +// .assign(to: \.authorAvatarImageURL, on: viewModel) +// .store(in: &disposeBag) +// // lock +// author.publisher(for: \.protected) +// .assign(to: \.protected, on: viewModel) +// .store(in: &disposeBag) +// // author name +// author.publisher(for: \.name) +// .map { PlaintextMetaContent(string: $0) } +// .assign(to: \.authorName, on: viewModel) +// .store(in: &disposeBag) +// // author username +// author.publisher(for: \.username) +// .map { $0 as String? } +// .assign(to: \.authorUsername, on: viewModel) +// .store(in: &disposeBag) +// // timestamp +// viewModel.dateTimeProvider = dateTimeProvider +// (status.repost ?? status).publisher(for: \.createdAt) +// .map { $0 as Date? } +// .assign(to: \.timestamp, on: viewModel) +// .store(in: &disposeBag) +// } +// +// private func configureContent(_ status: TwitterStatus) { +// guard let twitterTextProvider = viewModel.twitterTextProvider else { +// assertionFailure() +// return +// } +// +// let status = status.repost ?? status +// let content = TwitterContent(content: status.displayText) +// let metaContent = TwitterMetaContent.convert( +// content: content, +// urlMaximumLength: 20, +// twitterTextProvider: twitterTextProvider +// ) +// viewModel.spoilerContent = nil +// viewModel.isContentReveal = true +// viewModel.isContentSensitive = false +// viewModel.isContentSensitiveToggled = false +// viewModel.content = metaContent +// viewModel.sharePlaintextContent = status.displayText +// viewModel.language = status.language +// viewModel.source = status.source +// } +// +// private func configureMedia(_ status: TwitterStatus) { +// let status = status.repost ?? status +// +// mediaGridContainerView.viewModel.resetContentWarningOverlay() +// viewModel.isMediaSensitive = false +// viewModel.isMediaSensitiveToggled = false +// viewModel.isMediaSensitiveSwitchable = false +// viewModel.mediaViewConfigurations = MediaView.configuration(twitterStatus: status) +// } +// +// private func configurePoll(_ status: TwitterStatus) { +// let status = status.repost ?? status +// +// // pollItems +// status.publisher(for: \.poll) +// .sink { [weak self] poll in +// guard let self = self else { return } +// guard let poll = poll else { +// self.viewModel.pollItems = [] +// return +// } +// +// let options = poll.options.sorted(by: { $0.position < $1.position }) +// let items: [PollItem] = options.map { .option(record: .twitter(record: .init(objectID: $0.objectID))) } +// self.viewModel.pollItems = items +// } +// .store(in: &disposeBag) +// // isVoteButtonEnabled +// viewModel.isVoteButtonEnabled = false +// // isVotable +// viewModel.isVotable = false +// // votesCount +// if let poll = status.poll { +// poll.publisher(for: \.updatedAt) +// .map { _ in poll.options.map { Int($0.votes) }.reduce(0, +) } +// .assign(to: \.voteCount, on: viewModel) +// .store(in: &disposeBag) +// } +// // voterCount +// // none +// // expireAt +// viewModel.expireAt = status.poll?.endDatetime +// // expired +// viewModel.expired = status.poll?.votingStatus == .closed +// // isVoting +// viewModel.isVoting = false +// } +// +// private func configureLocation(_ status: TwitterStatus) { +// let status = status.repost ?? status +// status.publisher(for: \.location) +// .map { $0?.fullName } +// .assign(to: \.location, on: viewModel) +// .store(in: &disposeBag) +// } +// +// private func configureToolbar(_ status: TwitterStatus) { +// let status = status.repost ?? status +// +// status.publisher(for: \.replyCount) +// .map(Int.init) +// .assign(to: \.replyCount, on: viewModel) +// .store(in: &disposeBag) +// status.publisher(for: \.repostCount) +// .map(Int.init) +// .assign(to: \.repostCount, on: viewModel) +// .store(in: &disposeBag) +// status.publisher(for: \.quoteCount) +// .map(Int.init) +// .assign(to: \.quoteCount, on: viewModel) +// .store(in: &disposeBag) +// status.publisher(for: \.likeCount) +// .map(Int.init) +// .assign(to: \.likeCount, on: viewModel) +// .store(in: &disposeBag) +// viewModel.shareStatusURL = status.statusURL.absoluteString +// +// // relationship +// Publishers.CombineLatest( +// viewModel.$authenticationContext, +// status.publisher(for: \.repostBy) +// ) +// .map { authenticationContext, repostBy in +// guard let authenticationContext = authenticationContext?.twitterAuthenticationContext else { +// return false +// } +// let userID = authenticationContext.userID +// return repostBy.contains(where: { $0.id == userID }) +// } +// .assign(to: \.isRepost, on: viewModel) +// .store(in: &disposeBag) +// +// Publishers.CombineLatest( +// viewModel.$authenticationContext, +// status.publisher(for: \.likeBy) +// ) +// .map { authenticationContext, likeBy in +// guard let authenticationContext = authenticationContext?.twitterAuthenticationContext else { +// return false +// } +// let userID = authenticationContext.userID +// return likeBy.contains(where: { $0.id == userID }) +// } +// .assign(to: \.isLike, on: viewModel) +// .store(in: &disposeBag) +// +// let authorUserID = status.author.id +// viewModel.$authenticationContext +// .map { authenticationContext in +// guard let authenticationContext = authenticationContext?.twitterAuthenticationContext else { +// return false +// } +// return authenticationContext.userID == authorUserID +// } +// .assign(to: \.isDeletable, on: viewModel) +// .store(in: &disposeBag) +// } +// +// func configureReplySettings(_ status: TwitterStatus) { +// let status = status.repost ?? status +// +// viewModel.replySettings = status.replySettings?.typed +// } +// +//} +// +//// MARK: - Mastodon +// +//extension StatusView { +// public func configure( +// status: MastodonStatus, +// notification: MastodonNotification?, +// configurationContext: ConfigurationContext +// ) { +// viewModel.prepareForReuse() +// +// viewModel.authenticationContext = configurationContext.authContext.authenticationContext +// viewModel.managedObjectContext = status.managedObjectContext +// viewModel.objects.insert(status) +// +// viewModel.platform = .mastodon +// viewModel.dateTimeProvider = configurationContext.dateTimeProvider +// viewModel.twitterTextProvider = configurationContext.twitterTextProvider +// +// configureHeader(status, notification: notification) +// configureAuthor(status) +// configureContent(status) +// configureMedia(status) +// configurePoll(status) +// configureToolbar(status) +// } +// +// private func configureHeader( +// _ status: MastodonStatus, +// notification: MastodonNotification? +// ) { +// if let notification = notification { +// let user = notification.account +// let type = notification.notificationType +// Publishers.CombineLatest( +// user.publisher(for: \.displayName), +// user.publisher(for: \.emojis) +// ) +// .map { _ in +// guard let info = NotificationHeaderInfo(type: type, user: user) else { return .none } +// return ViewModel.Header.notification(info: info) +// } +// .assign(to: \.header, on: viewModel) +// .store(in: &disposeBag) +// } else if let _ = status.repost { +// Publishers.CombineLatest( +// status.author.publisher(for: \.displayName), +// status.author.publisher(for: \.emojis) +// ) +// .map { _, emojis -> StatusView.ViewModel.Header in +// let name = status.author.name +// let userRepostText = L10n.Common.Controls.Status.userBoosted(name) +// let content = MastodonContent(content: userRepostText, emojis: emojis.asDictionary) +// do { +// let metaContent = try MastodonMetaContent.convert(document: content) +// let info = ViewModel.Header.RepostInfo(authorNameMetaContent: metaContent) +// return .repost(info: info) +// } catch { +// assertionFailure(error.localizedDescription) +// let metaContent = PlaintextMetaContent(string: userRepostText) +// let info = ViewModel.Header.RepostInfo(authorNameMetaContent: metaContent) +// return .repost(info: info) +// } +// } +// .assign(to: \.header, on: viewModel) +// .store(in: &disposeBag) +// } else { +// viewModel.header = .none +// } +// } +// +// private func configureAuthor(_ status: MastodonStatus) { +// let author = (status.repost ?? status).author +// +// viewModel.userIdentifier = .mastodon(.init(domain: author.domain, id: author.id)) +// +// // author avatar +// author.publisher(for: \.avatar) +// .map { url in url.flatMap { URL(string: $0) } } +// .assign(to: \.authorAvatarImageURL, on: viewModel) +// .store(in: &disposeBag) +// // author name +// Publishers.CombineLatest( +// author.publisher(for: \.displayName), +// author.publisher(for: \.emojis) +// ) +// .map { _, emojis in +// do { +// let content = MastodonContent(content: author.name, emojis: emojis.asDictionary) +// let metaContent = try MastodonMetaContent.convert(document: content) +// return metaContent +// } catch { +// assertionFailure(error.localizedDescription) +// return PlaintextMetaContent(string: author.name) +// } +// } +// .assign(to: \.authorName, on: viewModel) +// .store(in: &disposeBag) +// // author username +// author.publisher(for: \.acct) +// .map { $0 as String? } +// .assign(to: \.authorUsername, on: viewModel) +// .store(in: &disposeBag) +// // protected +// author.publisher(for: \.locked) +// .assign(to: \.protected, on: viewModel) +// .store(in: &disposeBag) +// // visibility +// viewModel.visibility = status.visibility.asStatusVisibility +// // timestamp +// (status.repost ?? status).publisher(for: \.createdAt) +// .map { $0 as Date? } +// .assign(to: \.timestamp, on: viewModel) +// .store(in: &disposeBag) +// } +// +// private func configureContent(_ status: MastodonStatus) { +// let status = status.repost ?? status +// do { +// let content = MastodonContent(content: status.content, emojis: status.emojis.asDictionary) +// let metaContent = try MastodonMetaContent.convert(document: content) +// viewModel.content = metaContent +// viewModel.sharePlaintextContent = metaContent.original +// } catch { +// assertionFailure(error.localizedDescription) +// viewModel.content = PlaintextMetaContent(string: "") +// } +// +// if let spoilerText = status.spoilerText, !spoilerText.isEmpty { +// do { +// let content = MastodonContent(content: spoilerText, emojis: status.emojis.asDictionary) +// let metaContent = try MastodonMetaContent.convert(document: content) +// viewModel.spoilerContent = metaContent +// } catch { +// assertionFailure() +// viewModel.spoilerContent = nil +// } +// } else { +// viewModel.spoilerContent = nil +// } +// +// viewModel.isContentSensitiveToggled = status.isContentSensitiveToggled +// status.publisher(for: \.isContentSensitiveToggled) +// .assign(to: \.isContentSensitiveToggled, on: viewModel) +// .store(in: &disposeBag) +// +// viewModel.language = status.language +// viewModel.source = status.source +// } +// +// private func configureMedia(_ status: MastodonStatus) { +// let status = status.repost ?? status +// +// mediaGridContainerView.viewModel.resetContentWarningOverlay() +// viewModel.isMediaSensitiveSwitchable = true +// viewModel.mediaViewConfigurations = MediaView.configuration(mastodonStatus: status) +// +// // set directly without delay +// viewModel.isMediaSensitiveToggled = status.isMediaSensitiveToggled +// viewModel.isMediaSensitive = status.isMediaSensitive +// mediaGridContainerView.configureOverlayDisplay( +// isDisplay: status.isMediaSensitiveToggled ? !status.isMediaSensitive : !status.isMediaSensitive, +// animated: false +// ) +// +// status.publisher(for: \.isMediaSensitiveToggled) +// .receive(on: DispatchQueue.main) +// .assign(to: \.isMediaSensitiveToggled, on: viewModel) +// .store(in: &disposeBag) +// } +// +// private func configurePoll(_ status: MastodonStatus) { +// let status = status.repost ?? status +// +// // pollItems +// status.publisher(for: \.poll) +// .sink { [weak self] poll in +// guard let self = self else { return } +// guard let poll = poll else { +// self.viewModel.pollItems = [] +// return +// } +// +// let options = poll.options.sorted(by: { $0.index < $1.index }) +// let items: [PollItem] = options.map { .option(record: .mastodon(record: .init(objectID: $0.objectID))) } +// self.viewModel.pollItems = items +// } +// .store(in: &disposeBag) +// // isVoteButtonEnabled +// status.poll?.publisher(for: \.updatedAt) +// .sink { [weak self] _ in +// guard let self = self else { return } +// guard let poll = status.poll else { return } +// let options = poll.options +// let hasSelectedOption = options.contains(where: { $0.isSelected }) +// self.viewModel.isVoteButtonEnabled = hasSelectedOption +// } +// .store(in: &disposeBag) +// // isVotable +// if let poll = status.poll { +// Publishers.CombineLatest3( +// poll.publisher(for: \.voteBy), +// poll.publisher(for: \.expired), +// viewModel.$authenticationContext +// ) +// .map { voteBy, expired, authenticationContext in +// guard case let .mastodon(authenticationContext) = authenticationContext else { return false } +// let domain = authenticationContext.domain +// let userID = authenticationContext.userID +// let isVoted = voteBy.contains(where: { $0.domain == domain && $0.id == userID }) +// return !isVoted && !expired +// } +// .assign(to: &viewModel.$isVotable) +// } +// // votesCount +// status.poll?.publisher(for: \.votesCount) +// .map { Int($0) } +// .assign(to: \.voteCount, on: viewModel) +// .store(in: &disposeBag) +// // voterCount +// status.poll?.publisher(for: \.votersCount) +// .map { Int($0) } +// .assign(to: \.voterCount, on: viewModel) +// .store(in: &disposeBag) +// // expireAt +// status.poll?.publisher(for: \.expiresAt) +// .assign(to: \.expireAt, on: viewModel) +// .store(in: &disposeBag) +// // expired +// status.poll?.publisher(for: \.expired) +// .assign(to: \.expired, on: viewModel) +// .store(in: &disposeBag) +// // isVoting +// status.poll?.publisher(for: \.isVoting) +// .assign(to: \.isVoting, on: viewModel) +// .store(in: &disposeBag) +// } +// +// private func configureToolbar(_ status: MastodonStatus) { +// let status = status.repost ?? status +// +// viewModel.quoteCount = 0 +// status.publisher(for: \.replyCount) +// .map(Int.init) +// .assign(to: \.replyCount, on: viewModel) +// .store(in: &disposeBag) +// status.publisher(for: \.repostCount) +// .map(Int.init) +// .assign(to: \.repostCount, on: viewModel) +// .store(in: &disposeBag) +// status.publisher(for: \.likeCount) +// .map(Int.init) +// .assign(to: \.likeCount, on: viewModel) +// .store(in: &disposeBag) +// viewModel.shareStatusURL = status.url ?? status.uri +// +// // relationship +// Publishers.CombineLatest( +// viewModel.$authenticationContext, +// status.publisher(for: \.repostBy) +// ) +// .map { authenticationContext, repostBy in +// guard let authenticationContext = authenticationContext?.mastodonAuthenticationContext else { +// return false +// } +// let domain = authenticationContext.domain +// let userID = authenticationContext.userID +// return repostBy.contains(where: { $0.id == userID && $0.domain == domain }) +// } +// .assign(to: \.isRepost, on: viewModel) +// .store(in: &disposeBag) +// +// Publishers.CombineLatest( +// viewModel.$authenticationContext, +// status.publisher(for: \.likeBy) +// ) +// .map { authenticationContext, likeBy in +// guard let authenticationContext = authenticationContext?.mastodonAuthenticationContext else { +// return false +// } +// let domain = authenticationContext.domain +// let userID = authenticationContext.userID +// return likeBy.contains(where: { $0.id == userID && $0.domain == domain }) +// } +// .assign(to: \.isLike, on: viewModel) +// .store(in: &disposeBag) +// +// let authorUserID = status.author.id +// viewModel.$authenticationContext +// .map { authenticationContext in +// guard let authenticationContext = authenticationContext?.mastodonAuthenticationContext else { +// return false +// } +// return authenticationContext.userID == authorUserID +// } +// .assign(to: \.isDeletable, on: viewModel) +// .store(in: &disposeBag) +// } +// +//} diff --git a/TwidereSDK/Sources/TwidereUI/Content/StatusView+ViewModel.swift b/TwidereSDK/Sources/TwidereUI/Content/StatusView+ViewModel.swift index 425f5645..0b831841 100644 --- a/TwidereSDK/Sources/TwidereUI/Content/StatusView+ViewModel.swift +++ b/TwidereSDK/Sources/TwidereUI/Content/StatusView+ViewModel.swift @@ -12,7 +12,6 @@ import Combine import SwiftUI import CoreData import CoreDataStack -import TwidereCommon import TwidereAsset import TwidereLocalization import TwidereCore @@ -24,822 +23,1476 @@ import MastodonSDK extension StatusView { public final class ViewModel: ObservableObject { - static let pollOptionOrdinalNumberFormatter: NumberFormatter = { - let formatter = NumberFormatter() - formatter.numberStyle = .ordinal - return formatter - }() + + let logger = Logger(subsystem: "StatusView", category: "ViewModel") var disposeBag = Set() - var observations = Set() - var objects = Set() - let logger = Logger(subsystem: "StatusView", category: "ViewModel") + @Published public var viewLayoutFrame = ViewLayoutFrame() + + @Published public var repostViewModel: StatusView.ViewModel? + @Published public var quoteViewModel: StatusView.ViewModel? - @Published public var platform: Platform = .none - @Published public var authenticationContext: AuthenticationContext? // me - @Published public var managedObjectContext: NSManagedObjectContext? + // input + public let status: StatusObject? + public let author: UserObject? + public let authContext: AuthContext? + public let kind: Kind + public weak var delegate: StatusViewDelegate? - @Published public var header: Header = .none + weak var parentViewModel: StatusView.ViewModel? - @Published public var userIdentifier: UserIdentifier? - @Published public var authorAvatarImage: UIImage? - @Published public var authorAvatarImageURL: URL? - @Published public var authorName: MetaContent? - @Published public var authorUsername: String? + @Published public var addtionalHorizontalMargin: CGFloat = 0.0 + // output + + // header + @Published public var statusHeaderViewModel: StatusHeaderView.ViewModel? + + // author + @Published public var avatarURL: URL? + @Published public var avatarStyle = UserDefaults.shared.avatarStyle + + @Published public var authorName: MetaContent = PlaintextMetaContent(string: "") + @Published public var authorUsernme = "" + public let authorUserIdentifier: UserIdentifier? + @Published public var protected: Bool = false + public let isMyself: Bool - @Published public var isMyself = false +// static let pollOptionOrdinalNumberFormatter: NumberFormatter = { +// let formatter = NumberFormatter() +// formatter.numberStyle = .ordinal +// return formatter +// }() +// +// @Published public var authorAvatarImage: UIImage? +// @Published public var authorAvatarImageURL: URL? +// @Published public var authorUsername: String? +// + + // content @Published public var spoilerContent: MetaContent? + @Published public var content: MetaContent = PlaintextMetaContent(string: "") - @Published public var content: MetaContent? - @Published public var twitterTextProvider: TwitterTextProvider? - - @Published public var language: String? - @Published public var isTranslateButtonDisplay = false - - @Published public var mediaViewConfigurations: [MediaView.Configuration] = [] - - @Published public var isContentSensitive: Bool = false + var isContentEmpty: Bool { content.string.isEmpty } + var isContentSensitive: Bool { spoilerContent != nil } @Published public var isContentSensitiveToggled: Bool = false - - @Published public var isContentReveal: Bool = false - + public var isContentReveal: Bool { + return isContentSensitive ? isContentSensitiveToggled : !isContentEmpty + } + + // language + @Published public var language: String? + @Published public private(set) var translateButtonPreference: UserDefaults.TranslateButtonPreference? + public var isTranslateButtonDisplay: Bool { + // only display for conversation root + switch kind { + case .conversationRoot: break + default: return false + } + // check prefernece and compare device language + switch translateButtonPreference { + case .auto: + guard let language = language, !language.isEmpty else { + // default hidden + return false + } + let contentLocale = Locale(identifier: language) + guard let currentLanguageCode = Locale.current.language.languageCode?.identifier, + let contentLanguageCode = contentLocale.language.languageCode?.identifier + else { return true } + return currentLanguageCode != contentLanguageCode + case .always: return true + case .off: return false + case nil: return false + } + } + + // media + @Published public var mediaViewModels: [MediaView.ViewModel] = [] @Published public var isMediaSensitive: Bool = false @Published public var isMediaSensitiveToggled: Bool = false - - @Published public var isMediaSensitiveSwitchable = false - @Published public var isMediaReveal: Bool = false - - // poll input - @Published public var pollItems: [PollItem] = [] - @Published public var isVotable: Bool = false - @Published public var isVoting: Bool = false - @Published public var isVoteButtonEnabled: Bool = false - @Published public var voterCount: Int? - @Published public var voteCount = 0 - @Published public var expireAt: Date? - @Published public var expired: Bool = false - - // poll output - @Published public var pollVoteDescription = "" - @Published public var pollCountdownDescription: String? - - @Published public var location: String? - @Published public var source: String? - - @Published public var isRepost = false - @Published public var isRepostEnabled = true - - @Published public var isLike = false - - @Published public var replyCount: Int = 0 - @Published public var repostCount: Int = 0 - @Published public var quoteCount: Int = 0 - @Published public var likeCount: Int = 0 - - @Published public var visibility: StatusVisibility? - @Published public var replySettings: Twitter.Entity.V2.Tweet.ReplySettings? - - @Published public var dateTimeProvider: DateTimeProvider? - @Published public var timestamp: Date? - @Published public var timeAgoStyleTimestamp: String? - @Published public var formattedStyleTimestamp: String? - - @Published public var sharePlaintextContent: String? - @Published public var shareStatusURL: String? + public var isMediaContentWarningOverlayReveal: Bool { + return isMediaSensitiveToggled ? isMediaSensitive : !isMediaSensitive + } + public var isMediaContentWarningOverlayToggleButtonDisplay: Bool { + switch status { + case .twitter: return isMediaSensitive + default: return true + } + } - @Published public var isDeletable = false + // poll + @Published public var pollViewModel: PollView.ViewModel? + + // visibility + @Published public var visibility: MastodonVisibility? + var visibilityIconImage: UIImage? { + switch visibility { + case .public: + return Asset.ObjectTools.globeMiniInline.image.withRenderingMode(.alwaysTemplate) + case .unlisted: + return Asset.ObjectTools.lockOpenMiniInline.image.withRenderingMode(.alwaysTemplate) + case .private: + return Asset.ObjectTools.lockMiniInline.image.withRenderingMode(.alwaysTemplate) + case .direct: + return Asset.Communication.mailMiniInline.image.withRenderingMode(.alwaysTemplate) + case ._other: + assertionFailure() + return nil + case nil: + return nil + } + } + +// @Published public var groupedAccessibilityLabel = "" + + // timestamp + @Published public var timestampLabelViewModel: TimestampLabelView.ViewModel? - @Published public var groupedAccessibilityLabel = "" + // location + @Published public var location: String? - let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) - .autoconnect() - .share() - .eraseToAnyPublisher() + // metric + @Published public var metricViewModel: StatusMetricView.ViewModel? + + // toolbar + public let toolbarViewModel = StatusToolbarView.ViewModel() + public var canDelete: Bool { + guard let authContext = self.authContext else { return false } + guard let authorUserIdentifier = self.authorUserIdentifier else { return false } + return authContext.authenticationContext.userIdentifier == authorUserIdentifier + } - // public let contentRevealChangePublisher = PassthroughSubject() + // reply settings banner + @Published public var replySettingBannerViewModel: ReplySettingBannerView.ViewModel? + + // conversation link + @Published public var isTopConversationLinkLineViewDisplay = false + @Published public var isBottomConversationLinkLineViewDisplay = false - public enum Header { - case none - case repost(info: RepostInfo) - case notification(info: NotificationHeaderInfo) - // TODO: replyTo + private init( + status: StatusObject, + author: UserObject, + authContext: AuthContext?, + kind: Kind, + delegate: StatusViewDelegate?, + viewLayoutFramePublisher: Published.Publisher? + ) { + self.status = status + self.author = author + self.authContext = authContext + self.kind = kind + self.delegate = delegate + let _authorUserIdentifier: UserIdentifier = { + switch author { + case .twitter(let author): + return .twitter(.init(id: author.id)) + case .mastodon(let author): + return .mastodon(.init(domain: author.domain, id: author.id)) + } + }() + self.authorUserIdentifier = _authorUserIdentifier + self.isMyself = { + guard let myUserIdentifier = authContext?.authenticationContext.userIdentifier else { return false } + return myUserIdentifier == _authorUserIdentifier + }() + // end init - public struct RepostInfo { - public let authorNameMetaContent: MetaContent - } + viewLayoutFramePublisher?.assign(to: &$viewLayoutFrame) + +// // isContentSensitive +// Publishers.CombineLatest( +// $platform, +// $spoilerContent +// ) +// .map { platform, spoilerContent in +// switch platform { +// case .none: return false +// case .twitter: return false +// case .mastodon: return spoilerContent != nil +// } +// } +// .assign(to: &$isContentSensitive) +// // isContentReveal +// Publishers.CombineLatest( +// $isContentSensitive, +// $isContentSensitiveToggled +// ) +// .map { $0 ? $1 : !$1 } +// .assign(to: &$isContentReveal) +// // isMediaReveal +// Publishers.CombineLatest( +// $isMediaSensitive, +// $isMediaSensitiveToggled +// ) +// .map { $0 ? $1 : !$1 } +// .assign(to: &$isMediaReveal) +// // isRepostEnabled +// Publishers.CombineLatest4( +// $platform, +// $visibility, +// $protected, +// $isMyself +// ) +// .map { platform, visibility, protected, isMyself in +// switch platform { +// case .none: +// return true +// case .twitter: +// return isMyself ? true : !protected +// case .mastodon: +// if isMyself { +// return true +// } +// switch visibility { +// case .none: +// return true +// case .mastodon(let visibility): +// switch visibility { +// case .public, .unlisted: +// return true +// case .private, .direct, ._other: +// return false +// } +// } +// } +// } +// .assign(to: &$isRepostEnabled) + + setupBinding() } - public func prepareForReuse() { - replySettings = nil + private init( + viewLayoutFramePublisher: Published.Publisher? + ) { + self.status = nil + self.author = nil + self.authContext = nil + self.kind = .timeline + self.authorUserIdentifier = nil + self.isMyself = false + // end init + + viewLayoutFramePublisher?.assign(to: &$viewLayoutFrame) + + setupBinding() } - init() { - // isMyself - Publishers.CombineLatest( - $authenticationContext, - $userIdentifier - ) - .map { authenticationContext, userIdentifier in - guard let authenticationContext = authenticationContext, - let userIdentifier = userIdentifier - else { return false } - return authenticationContext.userIdentifier == userIdentifier - } - .assign(to: &$isMyself) - // isContentSensitive - Publishers.CombineLatest( - $platform, - $spoilerContent - ) - .map { platform, spoilerContent in - switch platform { - case .none: return false - case .twitter: return false - case .mastodon: return spoilerContent != nil - } - } - .assign(to: &$isContentSensitive) - // isContentReveal - Publishers.CombineLatest( - $isContentSensitive, - $isContentSensitiveToggled - ) - .map { $0 ? $1 : !$1 } - .assign(to: &$isContentReveal) - // isMediaReveal - Publishers.CombineLatest( - $isMediaSensitive, - $isMediaSensitiveToggled - ) - .map { $0 ? $1 : !$1 } - .assign(to: &$isMediaReveal) - // isRepostEnabled - Publishers.CombineLatest4( - $platform, - $visibility, - $protected, - $isMyself - ) - .map { platform, visibility, protected, isMyself in - switch platform { - case .none: - return true - case .twitter: - return isMyself ? true : !protected - case .mastodon: - if isMyself { - return true - } - switch visibility { - case .none: - return true - case .mastodon(let visibility): - switch visibility { - case .public, .unlisted: - return true - case .private, .direct, ._other: - return false - } - } - } - } - .assign(to: &$isRepostEnabled) + private func setupBinding() { + // avatar style + UserDefaults.shared.publisher(for: \.avatarStyle) + .assign(to: &$avatarStyle) - Publishers.CombineLatest( - UserDefaults.shared.publisher(for: \.translateButtonPreference), - $language + // translate button + UserDefaults.shared.publisher(for: \.translateButtonPreference) + .map { $0 } + .assign(to: &$translateButtonPreference) + + // toolbar + toolbarViewModel.style = kind == .conversationRoot ? .plain : .inline + } + } +} + +extension StatusView.ViewModel { + @MainActor + public func updateTwitterStatusContent(statusID: TwitterStatus.ID) async throws { + do { + guard case let .twitter(authenticationContext) = authContext?.authenticationContext else { return } + let response = try await Twitter.API.V2.Status.detail( + session: URLSession(configuration: .ephemeral), + query: .init(statusID: statusID), + authorization: authenticationContext.authorization ) - .map { preference, language -> Bool in - switch preference { - case .auto: - guard let language = language, !language.isEmpty else { - // default hidden - return false - } - let contentLocale = Locale(identifier: language) - guard let currentLanguageCode = Locale.current.languageCode, - let contentLanguageCode = contentLocale.languageCode - else { return true } - return currentLanguageCode != contentLanguageCode - case .always: return true - case .off: return false - } - } - .assign(to: &$isTranslateButtonDisplay) + let metaContent = TwitterMetaContent.convert( + document: TwitterContent(content: response.value.text, urlEntities: response.value.urlEntities), + urlMaximumLength: .max, + twitterTextProvider: SwiftTwitterTextProvider(), + useParagraphMark: true + ) + self.content = metaContent + // delegate?.statusView(self, translateContentDidChange: status) + } catch { + debugPrint(error.localizedDescription) + throw error } } } +//extension StatusView.ViewModel { +// func bind(statusView: StatusView) { +// bindHeader(statusView: statusView) +// bindAuthor(statusView: statusView) +// bindContent(statusView: statusView) +// bindMedia(statusView: statusView) +// bindPoll(statusView: statusView) +// bindLocation(statusView: statusView) +// bindToolbar(statusView: statusView) +// bindReplySettings(statusView: statusView) +// bindAccessibility(statusView: statusView) +// } +// +// private func bindHeader(statusView: StatusView) { +// $header +// .sink { header in +// switch header { +// case .none: +// return +// case .repost(let info): +// statusView.headerIconImageView.image = Asset.Media.repeat.image +// statusView.headerIconImageView.tintColor = Asset.Colors.Theme.daylight.color +// statusView.headerTextLabel.setupAttributes(style: StatusView.headerTextLabelStyle) +// statusView.headerTextLabel.configure(content: info.authorNameMetaContent) +// statusView.setHeaderDisplay() +// case .notification(let info): +// statusView.headerIconImageView.image = info.iconImage +// statusView.headerIconImageView.tintColor = info.iconImageTintColor +// statusView.headerTextLabel.setupAttributes(style: StatusView.headerTextLabelStyle) +// statusView.headerTextLabel.configure(content: info.textMetaContent) +// statusView.setHeaderDisplay() +// } +// } +// .store(in: &disposeBag) +// } +// +// private func bindAuthor(statusView: StatusView) { +// // avatar +// Publishers.CombineLatest( +// $authorAvatarImage, +// $authorAvatarImageURL +// ) +// .sink { image, url in +// let configuration: AvatarImageView.Configuration = { +// if let image = image { +// return AvatarImageView.Configuration(image: image) +// } else { +// return AvatarImageView.Configuration(url: url) +// } +// }() +// statusView.authorAvatarButton.avatarImageView.configure(configuration: configuration) +// } +// .store(in: &disposeBag) +// UserDefaults.shared +// .observe(\.avatarStyle, options: [.initial, .new]) { defaults, _ in +// +// let avatarStyle = defaults.avatarStyle +// let animator = UIViewPropertyAnimator(duration: 0.3, timingParameters: UISpringTimingParameters()) +// animator.addAnimations { +// switch avatarStyle { +// case .circle: +// statusView.authorAvatarButton.avatarImageView.configure(cornerConfiguration: .init(corner: .circle)) +// case .roundedSquare: +// statusView.authorAvatarButton.avatarImageView.configure(cornerConfiguration: .init(corner: .scale(ratio: 4))) +// } +// } +// animator.startAnimation() +// } +// .store(in: &observations) +// // lock +// $protected +// .sink { protected in +// statusView.lockImageView.isHidden = !protected +// } +// .store(in: &disposeBag) +// // name +// $authorName +// .sink { metaContent in +// let metaContent = metaContent ?? PlaintextMetaContent(string: "") +// statusView.authorNameLabel.setupAttributes(style: StatusView.authorNameLabelStyle) +// statusView.authorNameLabel.configure(content: metaContent) +// } +// .store(in: &disposeBag) +// // username +// $authorUsername +// .map { text in +// guard let text = text else { return "" } +// return "@\(text)" +// } +// .assign(to: \.text, on: statusView.authorUsernameLabel) +// .store(in: &disposeBag) +// // visibility +// $visibility +// .sink { visibility in +// guard let visibility = visibility, +// let image = visibility.inlineImage +// else { return } +// +// statusView.visibilityImageView.image = image +// statusView.visibilityImageView.accessibilityLabel = visibility.accessibilityLabel +// statusView.visibilityImageView.accessibilityTraits = .staticText +// statusView.visibilityImageView.isAccessibilityElement = true +// statusView.setVisibilityDisplay() +// } +// .store(in: &disposeBag) +// // timestamp +// Publishers.CombineLatest3( +// $timestamp, +// $dateTimeProvider, +// timestampUpdatePublisher.prepend(Date()).eraseToAnyPublisher() +// ) +// .sink { [weak self] timestamp, dateTimeProvider, _ in +// guard let self = self else { return } +// self.timeAgoStyleTimestamp = dateTimeProvider?.shortTimeAgoSinceNow(to: timestamp) +// self.formattedStyleTimestamp = { +// let formatter = DateFormatter() +// formatter.dateStyle = .medium +// formatter.timeStyle = .medium +// let text = timestamp.flatMap { formatter.string(from: $0) } +// return text +// }() +// } +// .store(in: &disposeBag) +// $timeAgoStyleTimestamp +// .sink { timestamp in +// statusView.timestampLabel.text = timestamp +// } +// .store(in: &disposeBag) +// $formattedStyleTimestamp +// .sink { timestamp in +// statusView.metricsDashboardView.timestampLabel.text = timestamp +// } +// .store(in: &disposeBag) +// } +// +// private func bindContent(statusView: StatusView) { +// $content +// .sink { metaContent in +// guard let content = metaContent else { +// statusView.contentTextView.reset() +// return +// } +// statusView.contentTextView.configure(content: content) +// } +// .store(in: &disposeBag) +// $spoilerContent +// .sink { metaContent in +// guard let metaContent = metaContent else { +// statusView.spoilerContentTextView.reset() +// return +// } +// statusView.spoilerContentTextView.configure(content: metaContent) +// statusView.setSpoilerDisplay() +// } +// .store(in: &disposeBag) +// $isContentReveal +// .sink { isContentReveal in +// statusView.contentTextView.isHidden = !isContentReveal +// +// let label = isContentReveal ? L10n.Accessibility.Common.Status.Actions.hideContent : L10n.Accessibility.Common.Status.Actions.revealContent +// statusView.expandContentButton.accessibilityLabel = label +// } +// .store(in: &disposeBag) +// $isTranslateButtonDisplay +// .sink { isTranslateButtonDisplay in +// if isTranslateButtonDisplay { +// statusView.setTranslateButtonDisplay() +// } +// } +// .store(in: &disposeBag) +// $source +// .sink { source in +// statusView.metricsDashboardView.sourceLabel.text = source ?? "" +// } +// .store(in: &disposeBag) +// // dashboard +// $platform +// .assign(to: \.platform, on: statusView.metricsDashboardView.viewModel) +// .store(in: &disposeBag) +// Publishers.CombineLatest4( +// $replyCount, +// $repostCount, +// $quoteCount, +// $likeCount +// ) +// .sink { replyCount, repostCount, quoteCount, likeCount in +// switch statusView.style { +// case .plain: +// statusView.metricsDashboardView.viewModel.replyCount = replyCount +// statusView.metricsDashboardView.viewModel.repostCount = repostCount +// statusView.metricsDashboardView.viewModel.quoteCount = quoteCount +// statusView.metricsDashboardView.viewModel.likeCount = likeCount +// default: +// break +// } +// } +// .store(in: &disposeBag) +// } +// +// private func bindMedia(statusView: StatusView) { +// $mediaViewConfigurations +// .sink { [weak self] configurations in +// guard let self = self else { return } +// // self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): configure media") +// +// let maxSize = CGSize( +// width: statusView.contentMaxLayoutWidth, +// height: statusView.contentMaxLayoutWidth +// ) +// var needsDisplay = true +// switch configurations.count { +// case 0: +// needsDisplay = false +// case 1: +// let configuration = configurations[0] +// let adaptiveLayout = MediaGridContainerView.AdaptiveLayout( +// aspectRatio: configuration.aspectRadio, +// maxSize: maxSize +// ) +// let mediaView = statusView.mediaGridContainerView.dequeueMediaView(adaptiveLayout: adaptiveLayout) +// mediaView.setup(configuration: configuration) +// default: +// let gridLayout = MediaGridContainerView.GridLayout( +// count: configurations.count, +// maxSize: maxSize +// ) +// let mediaViews = statusView.mediaGridContainerView.dequeueMediaView(gridLayout: gridLayout) +// for (i, (configuration, mediaView)) in zip(configurations, mediaViews).enumerated() { +// guard i < MediaGridContainerView.maxCount else { break } +// mediaView.setup(configuration: configuration) +// } +// } +// if needsDisplay { +// statusView.setMediaDisplay() +// } +// } +// .store(in: &disposeBag) +// $isMediaReveal +// .sink { isMediaReveal in +// statusView.mediaGridContainerView.viewModel.isContentWarningOverlayDisplay = !isMediaReveal +// } +// .store(in: &disposeBag) +// $isMediaSensitiveSwitchable +// .sink { isMediaSensitiveSwitchable in +// statusView.mediaGridContainerView.viewModel.isSensitiveToggleButtonDisplay = isMediaSensitiveSwitchable +// } +// .store(in: &disposeBag) +// } +// +// private func bindPoll(statusView: StatusView) { +// $pollItems +// .sink { items in +// guard !items.isEmpty else { return } +// +// var snapshot = NSDiffableDataSourceSnapshot() +// snapshot.appendSections([.main]) +// snapshot.appendItems(items, toSection: .main) +// statusView.pollTableViewDiffableDataSource?.applySnapshotUsingReloadData(snapshot) +// +// statusView.pollTableViewHeightLayoutConstraint.constant = CGFloat(items.count) * PollOptionTableViewCell.height +// statusView.setPollDisplay() +// } +// .store(in: &disposeBag) +// $isVotable +// .sink { isVotable in +// statusView.pollTableView.allowsSelection = isVotable +// } +// .store(in: &disposeBag) +// // poll +// Publishers.CombineLatest( +// $voterCount, +// $voteCount +// ) +// .map { voterCount, voteCount -> String in +// var description = "" +// if let voterCount = voterCount { +// description += L10n.Count.people(voterCount) +// } else { +// description += L10n.Count.vote(voteCount) +// } +// return description +// } +// .assign(to: &$pollVoteDescription) +// Publishers.CombineLatest3( +// $expireAt, +// $expired, +// timestampUpdatePublisher.prepend(Date()).eraseToAnyPublisher() +// ) +// .map { expireAt, expired, _ -> String? in +// guard !expired else { +// return L10n.Common.Controls.Status.Poll.expired +// } +// +// guard let expireAt = expireAt, +// let timeLeft = expireAt.localizedTimeLeft +// else { +// return nil +// } +// +// return timeLeft +// } +// .assign(to: &$pollCountdownDescription) +// Publishers.CombineLatest( +// $pollVoteDescription, +// $pollCountdownDescription +// ) +// .sink { pollVoteDescription, pollCountdownDescription in +// let description = [ +// pollVoteDescription, +// pollCountdownDescription +// ] +// .compactMap { $0 } +// +// statusView.pollVoteDescriptionLabel.text = description.joined(separator: " · ") +// statusView.pollVoteDescriptionLabel.accessibilityLabel = description.joined(separator: ", ") +// } +// .store(in: &disposeBag) +// Publishers.CombineLatest( +// $isVotable, +// $isVoting +// ) +// .sink { isVotable, isVoting in +// guard isVotable else { +// statusView.pollVoteButton.isHidden = true +// statusView.pollVoteActivityIndicatorView.isHidden = true +// return +// } +// +// statusView.pollVoteButton.isHidden = isVoting +// statusView.pollVoteActivityIndicatorView.isHidden = !isVoting +// statusView.pollVoteActivityIndicatorView.startAnimating() +// } +// .store(in: &disposeBag) +// $isVoteButtonEnabled +// .assign(to: \.isEnabled, on: statusView.pollVoteButton) +// .store(in: &disposeBag) +// } +// +// private func bindLocation(statusView: StatusView) { +// $location +// .sink { location in +// guard let location = location, !location.isEmpty else { +// statusView.locationLabel.isAccessibilityElement = false +// return +// } +// statusView.locationLabel.isAccessibilityElement = true +// +// if statusView.traitCollection.preferredContentSizeCategory > .extraLarge { +// statusView.locationMapPinImageView.image = Asset.ObjectTools.mappin.image +// } else { +// statusView.locationMapPinImageView.image = Asset.ObjectTools.mappinMini.image +// } +// statusView.locationLabel.text = location +// statusView.locationLabel.accessibilityLabel = location +// +// statusView.setLocationDisplay() +// } +// .store(in: &disposeBag) +// } +// +// private func bindToolbar(statusView: StatusView) { +// // platform +// $platform +// .assign(to: \.platform, on: statusView.toolbar.viewModel) +// .store(in: &disposeBag) +// // reply +// $replyCount +// .sink { count in +// statusView.toolbar.setupReply(count: count, isEnabled: true) // TODO: +// } +// .store(in: &disposeBag) +// // repost +// Publishers.CombineLatest3( +// $repostCount, +// $isRepost, +// $isRepostEnabled +// ) +// .sink { count, isRepost, isEnabled in +// statusView.toolbar.setupRepost(count: count, isEnabled: isEnabled, isHighlighted: isRepost) +// } +// .store(in: &disposeBag) +// // like +// Publishers.CombineLatest( +// $likeCount, +// $isLike +// ) +// .sink { count, isLike in +// statusView.toolbar.setupLike(count: count, isHighlighted: isLike) +// } +// .store(in: &disposeBag) +// // menu +// Publishers.CombineLatest4( +// $sharePlaintextContent, +// $shareStatusURL, +// $mediaViewConfigurations, +// $isDeletable +// ) +// .sink { sharePlaintextContent, shareStatusURL, mediaViewConfigurations, isDeletable in +// statusView.toolbar.setupMenu(menuContext: .init( +// shareText: sharePlaintextContent, +// shareLink: shareStatusURL, +// displaySaveMediaAction: !mediaViewConfigurations.isEmpty, +// displayDeleteAction: isDeletable +// )) +// } +// .store(in: &disposeBag) +// } +// +// private func bindReplySettings(statusView: StatusView) { +// Publishers.CombineLatest( +// $replySettings, +// $authorUsername +// ) +// .sink { replySettings, authorUsername in +// guard let replySettings = replySettings else { return } +// guard let authorUsername = authorUsername else { return } +// switch replySettings { +// case .everyone: +// return +// case .following: +// statusView.replySettingBannerView.imageView.image = Asset.Communication.at.image.withRenderingMode(.alwaysTemplate) +// statusView.replySettingBannerView.label.text = L10n.Common.Controls.Status.ReplySettings.peopleUserFollowsOrMentionedCanReply("@\(authorUsername)") +// case .mentionedUsers: +// statusView.replySettingBannerView.imageView.image = Asset.Human.personCheckMini.image.withRenderingMode(.alwaysTemplate) +// statusView.replySettingBannerView.label.text = L10n.Common.Controls.Status.ReplySettings.peopleUserMentionedCanReply("@\(authorUsername)") +// } +// statusView.setReplySettingsDisplay() +// } +// .store(in: &disposeBag) +// } +// +// private func bindAccessibility(statusView: StatusView) { +// let authorAccessibilityLabel = Publishers.CombineLatest( +// $header, +// $authorName +// ) +// .map { header, authorName -> String? in +// var strings: [String?] = [] +// +// switch header { +// case .none: +// break +// case .notification(let info): +// strings.append(info.textMetaContent.string) +// case .repost(let info): +// strings.append(info.authorNameMetaContent.string) +// } +// +// strings.append(authorName?.string) +// +// return strings.compactMap { $0 }.joined(separator: ", ") +// } +// +// let metaAccessibilityLabel = Publishers.CombineLatest( +// $timeAgoStyleTimestamp, +// $visibility +// ) +// .map { timestamp, visibility -> String? in +// var strings: [String?] = [] +// +// strings.append(visibility?.accessibilityLabel) +// strings.append(timestamp) +// +// return strings.compactMap { $0 }.joined(separator: ", ") +// } +// +// let contentAccessibilityLabel = Publishers.CombineLatest4( +// $platform, +// $isContentReveal, +// $spoilerContent, +// $content +// ) +// .map { platform, isContentReveal, spoilerContent, content -> String? in +// var strings: [String?] = [] +// switch platform { +// case .none: +// break +// case .twitter: +// strings.append(content?.string) +// case .mastodon: +// if let spoilerContent = spoilerContent?.string { +// strings.append(L10n.Accessibility.Common.Status.contentWarning) +// strings.append(spoilerContent) +// } +// if isContentReveal { +// strings.append(content?.string) +// } +// } +// +// return strings.compactMap { $0 }.joined(separator: ", ") +// } +// +// let mediaAccessibilityLabel = $mediaViewConfigurations +// .map { configurations -> String? in +// let count = configurations.count +// return count > 0 ? L10n.Count.media(count) : nil +// } +// +// let toolbarAccessibilityLabel = Publishers.CombineLatest3( +// $platform, +// $isRepost, +// $isLike +// ) +// .map { platform, isRepost, isLike -> String? in +// var strings: [String?] = [] +// +// switch platform { +// case .none: +// break +// case .twitter: +// if isRepost { +// strings.append(L10n.Accessibility.Common.Status.retweeted) +// } +// case .mastodon: +// if isRepost { +// strings.append(L10n.Accessibility.Common.Status.boosted) +// } +// } +// +// if isLike { +// strings.append(L10n.Accessibility.Common.Status.liked) +// } +// +// return strings.compactMap { $0 }.joined(separator: ", ") +// } +// +// let pollAccessibilityLabel = Publishers.CombineLatest3( +// $pollItems, +// $pollVoteDescription, +// $pollCountdownDescription +// ) +// .map { items, pollVoteDescription, pollCountdownDescription -> String? in +// guard !items.isEmpty else { return nil } +// guard let managedObjectContext = self.managedObjectContext else { return nil } +// +// var strings: [String?] = [] +// +// let ordinalPrefix = L10n.Accessibility.Common.Status.pollOptionOrdinalPrefix +// +// for (i, item) in items.enumerated() { +// switch item { +// case .option(let record): +// guard let option = record.object(in: managedObjectContext) else { continue } +// let number = NSNumber(value: i + 1) +// guard let ordinal = StatusView.ViewModel.pollOptionOrdinalNumberFormatter.string(from: number) else { break } +// strings.append("\(ordinalPrefix), \(ordinal), \(option.title)") +// +// if option.isSelected { +// strings.append(L10n.Accessibility.VoiceOver.selected) +// } +// } +// } +// +// strings.append(pollVoteDescription) +// pollCountdownDescription.flatMap { strings.append($0) } +// +// return strings.compactMap { $0 }.joined(separator: ", ") +// } +// +// let groupOne = Publishers.CombineLatest4( +// authorAccessibilityLabel, +// metaAccessibilityLabel, +// contentAccessibilityLabel, +// mediaAccessibilityLabel +// ) +// .map { a, b, c, d -> String? in +// return [a, b, c, d] +// .compactMap { $0 } +// .joined(separator: ", ") +// } +// +// let groupTwo = Publishers.CombineLatest3( +// pollAccessibilityLabel, +// $location, +// toolbarAccessibilityLabel +// ) +// .map { a, b, c -> String? in +// return [a, b, c] +// .compactMap { $0 } +// .joined(separator: ", ") +// } +// +// Publishers.CombineLatest( +// groupOne, +// groupTwo +// ) +// .map { a, b -> String in +// return [a, b] +// .compactMap { $0 } +// .joined(separator: ", ") +// } +// .assign(to: &$groupedAccessibilityLabel) +// +// $groupedAccessibilityLabel +// .sink { accessibilityLabel in +// statusView.accessibilityLabel = accessibilityLabel +// } +// .store(in: &disposeBag) +// +// // poll +// $pollItems +// .sink { items in +// statusView.pollVoteDescriptionLabel.isAccessibilityElement = !items.isEmpty +// statusView.pollVoteButton.isAccessibilityElement = !items.isEmpty +// } +// .store(in: &disposeBag) +// } +// +//} + +extension StatusView.ViewModel { + public enum Kind { + case timeline + case repost + case quote + case referenceReplyTo + case referenceQuote + case conversationRoot + case conversationThread + } +} + extension StatusView.ViewModel { - func bind(statusView: StatusView) { - bindHeader(statusView: statusView) - bindAuthor(statusView: statusView) - bindContent(statusView: statusView) - bindMedia(statusView: statusView) - bindPoll(statusView: statusView) - bindLocation(statusView: statusView) - bindToolbar(statusView: statusView) - bindReplySettings(statusView: statusView) - bindAccessibility(statusView: statusView) + var contentWidth: CGFloat { + let width = containerWidth - 2 * margin + return max(width, .leastNonzeroMagnitude) } - private func bindHeader(statusView: StatusView) { - $header - .sink { header in - switch header { - case .none: - return - case .repost(let info): - statusView.headerIconImageView.image = Asset.Media.repeat.image - statusView.headerIconImageView.tintColor = Asset.Colors.Theme.daylight.color - statusView.headerTextLabel.setupAttributes(style: StatusView.headerTextLabelStyle) - statusView.headerTextLabel.configure(content: info.authorNameMetaContent) - statusView.setHeaderDisplay() - case .notification(let info): - statusView.headerIconImageView.image = info.iconImage - statusView.headerIconImageView.tintColor = info.iconImageTintColor - statusView.headerTextLabel.setupAttributes(style: StatusView.headerTextLabelStyle) - statusView.headerTextLabel.configure(content: info.textMetaContent) - statusView.setHeaderDisplay() - } - } - .store(in: &disposeBag) + var containerWidth: CGFloat { + let width: CGFloat = { + var width = parentViewModel?.containerWidth ?? (viewLayoutFrame.readableContentLayoutFrame.width - addtionalHorizontalMargin) + width -= containerMargin + return width + }() + return max(width, .leastNonzeroMagnitude) } - private func bindAuthor(statusView: StatusView) { - // avatar - Publishers.CombineLatest( - $authorAvatarImage, - $authorAvatarImageURL - ) - .sink { image, url in - let configuration: AvatarImageView.Configuration = { - if let image = image { - return AvatarImageView.Configuration(image: image) - } else { - return AvatarImageView.Configuration(url: url) - } - }() - statusView.authorAvatarButton.avatarImageView.configure(configuration: configuration) + var containerMargin: CGFloat { + var width: CGFloat = 0 + + // container margin + switch kind { + case .conversationThread: + fallthrough + case .referenceReplyTo: + fallthrough + case .referenceQuote: + fallthrough + case .timeline: + width += StatusView.hangingAvatarButtonDimension + width += StatusView.hangingAvatarButtonTrailingSpacing + case .repost: + break + case .quote: + break + case .conversationRoot: + break } - .store(in: &disposeBag) - UserDefaults.shared - .observe(\.avatarStyle, options: [.initial, .new]) { defaults, _ in - - let avatarStyle = defaults.avatarStyle - let animator = UIViewPropertyAnimator(duration: 0.3, timingParameters: UISpringTimingParameters()) - animator.addAnimations { - switch avatarStyle { - case .circle: - statusView.authorAvatarButton.avatarImageView.configure(cornerConfiguration: .init(corner: .circle)) - case .roundedSquare: - statusView.authorAvatarButton.avatarImageView.configure(cornerConfiguration: .init(corner: .scale(ratio: 4))) - } - } - animator.startAnimation() - } - .store(in: &observations) - // lock - $protected - .sink { protected in - statusView.lockImageView.isHidden = !protected - } - .store(in: &disposeBag) - // name - $authorName - .sink { metaContent in - let metaContent = metaContent ?? PlaintextMetaContent(string: "") - statusView.authorNameLabel.setupAttributes(style: StatusView.authorNameLabelStyle) - statusView.authorNameLabel.configure(content: metaContent) - } - .store(in: &disposeBag) - // username - $authorUsername - .map { text in - guard let text = text else { return "" } - return "@\(text)" - } - .assign(to: \.text, on: statusView.authorUsernameLabel) - .store(in: &disposeBag) - // visibility - $visibility - .sink { visibility in - guard let visibility = visibility, - let image = visibility.inlineImage - else { return } - - statusView.visibilityImageView.image = image - statusView.visibilityImageView.accessibilityLabel = visibility.accessibilityLabel - statusView.visibilityImageView.accessibilityTraits = .staticText - statusView.visibilityImageView.isAccessibilityElement = true - statusView.setVisibilityDisplay() + + // manually readable margin (iPad multi-column layout) + switch kind { + case .timeline: + fallthrough + case .conversationThread: + fallthrough + case .conversationRoot: + if viewLayoutFrame.layoutFrame.width == viewLayoutFrame.readableContentLayoutFrame.width { + width += 2 * 16 } - .store(in: &disposeBag) - // timestamp - Publishers.CombineLatest3( - $timestamp, - $dateTimeProvider, - timestampUpdatePublisher.prepend(Date()).eraseToAnyPublisher() - ) - .sink { [weak self] timestamp, dateTimeProvider, _ in - guard let self = self else { return } - self.timeAgoStyleTimestamp = dateTimeProvider?.shortTimeAgoSinceNow(to: timestamp) - self.formattedStyleTimestamp = { - let formatter = DateFormatter() - formatter.dateStyle = .medium - formatter.timeStyle = .medium - let text = timestamp.flatMap { formatter.string(from: $0) } - return text - }() + case .referenceReplyTo: + break + case .referenceQuote: + break + case .repost: + break + case .quote: + break } - .store(in: &disposeBag) - $timeAgoStyleTimestamp - .sink { timestamp in - statusView.timestampLabel.text = timestamp - } - .store(in: &disposeBag) - $formattedStyleTimestamp - .sink { timestamp in - statusView.metricsDashboardView.timestampLabel.text = timestamp - } - .store(in: &disposeBag) + + return width } - private func bindContent(statusView: StatusView) { - $content - .sink { metaContent in - guard let content = metaContent else { - statusView.contentTextView.reset() - return - } - statusView.contentTextView.configure(content: content) - } - .store(in: &disposeBag) - $spoilerContent - .sink { metaContent in - guard let metaContent = metaContent else { - statusView.spoilerContentTextView.reset() - return - } - statusView.spoilerContentTextView.configure(content: metaContent) - statusView.setSpoilerDisplay() - } - .store(in: &disposeBag) - $isContentReveal - .sink { isContentReveal in - statusView.contentTextView.isHidden = !isContentReveal - - let label = isContentReveal ? L10n.Accessibility.Common.Status.Actions.hideContent : L10n.Accessibility.Common.Status.Actions.revealContent - statusView.expandContentButton.accessibilityLabel = label - } - .store(in: &disposeBag) - $isTranslateButtonDisplay - .sink { isTranslateButtonDisplay in - if isTranslateButtonDisplay { - statusView.setTranslateButtonDisplay() - } - } - .store(in: &disposeBag) - $source - .sink { source in - statusView.metricsDashboardView.sourceLabel.text = source ?? "" - } - .store(in: &disposeBag) - // dashboard - $platform - .assign(to: \.platform, on: statusView.metricsDashboardView.viewModel) - .store(in: &disposeBag) - Publishers.CombineLatest4( - $replyCount, - $repostCount, - $quoteCount, - $likeCount - ) - .sink { replyCount, repostCount, quoteCount, likeCount in - switch statusView.style { - case .plain: - statusView.metricsDashboardView.viewModel.replyCount = replyCount - statusView.metricsDashboardView.viewModel.repostCount = repostCount - statusView.metricsDashboardView.viewModel.quoteCount = quoteCount - statusView.metricsDashboardView.viewModel.likeCount = likeCount - default: - break - } + var margin: CGFloat { + switch kind { + case .quote: return 12 + default: return .zero } - .store(in: &disposeBag) - } - - private func bindMedia(statusView: StatusView) { - $mediaViewConfigurations - .sink { [weak self] configurations in - guard let self = self else { return } - // self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): configure media") - - let maxSize = CGSize( - width: statusView.contentMaxLayoutWidth, - height: statusView.contentMaxLayoutWidth - ) - var needsDisplay = true - switch configurations.count { - case 0: - needsDisplay = false - case 1: - let configuration = configurations[0] - let adaptiveLayout = MediaGridContainerView.AdaptiveLayout( - aspectRatio: configuration.aspectRadio, - maxSize: maxSize - ) - let mediaView = statusView.mediaGridContainerView.dequeueMediaView(adaptiveLayout: adaptiveLayout) - mediaView.setup(configuration: configuration) - default: - let gridLayout = MediaGridContainerView.GridLayout( - count: configurations.count, - maxSize: maxSize - ) - let mediaViews = statusView.mediaGridContainerView.dequeueMediaView(gridLayout: gridLayout) - for (i, (configuration, mediaView)) in zip(configurations, mediaViews).enumerated() { - guard i < MediaGridContainerView.maxCount else { break } - mediaView.setup(configuration: configuration) - } - } - if needsDisplay { - statusView.setMediaDisplay() - } - } - .store(in: &disposeBag) - $isMediaReveal - .sink { isMediaReveal in - statusView.mediaGridContainerView.viewModel.isContentWarningOverlayDisplay = !isMediaReveal - } - .store(in: &disposeBag) - $isMediaSensitiveSwitchable - .sink { isMediaSensitiveSwitchable in - statusView.mediaGridContainerView.viewModel.isSensitiveToggleButtonDisplay = isMediaSensitiveSwitchable - } - .store(in: &disposeBag) } - private func bindPoll(statusView: StatusView) { - $pollItems - .sink { items in - guard !items.isEmpty else { return } - - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.main]) - snapshot.appendItems(items, toSection: .main) - statusView.pollTableViewDiffableDataSource?.applySnapshotUsingReloadData(snapshot) - - statusView.pollTableViewHeightLayoutConstraint.constant = CGFloat(items.count) * PollOptionTableViewCell.height - statusView.setPollDisplay() - } - .store(in: &disposeBag) - $isVotable - .sink { isVotable in - statusView.pollTableView.allowsSelection = isVotable - } - .store(in: &disposeBag) - // poll - Publishers.CombineLatest( - $voterCount, - $voteCount - ) - .map { voterCount, voteCount -> String in - var description = "" - if let voterCount = voterCount { - description += L10n.Count.people(voterCount) - } else { - description += L10n.Count.vote(voteCount) - } - return description + var hasHangingAvatar: Bool { + switch kind { + case .conversationRoot, .quote: + return false + default: + return true } - .assign(to: &$pollVoteDescription) - Publishers.CombineLatest3( - $expireAt, - $expired, - timestampUpdatePublisher.prepend(Date()).eraseToAnyPublisher() - ) - .map { expireAt, expired, _ -> String? in - guard !expired else { - return L10n.Common.Controls.Status.Poll.expired - } - - guard let expireAt = expireAt, - let timeLeft = expireAt.localizedTimeLeft - else { - return nil - } - - return timeLeft - } - .assign(to: &$pollCountdownDescription) - Publishers.CombineLatest( - $pollVoteDescription, - $pollCountdownDescription - ) - .sink { pollVoteDescription, pollCountdownDescription in - let description = [ - pollVoteDescription, - pollCountdownDescription - ] - .compactMap { $0 } - - statusView.pollVoteDescriptionLabel.text = description.joined(separator: " · ") - statusView.pollVoteDescriptionLabel.accessibilityLabel = description.joined(separator: ", ") - } - .store(in: &disposeBag) - Publishers.CombineLatest( - $isVotable, - $isVoting - ) - .sink { isVotable, isVoting in - guard isVotable else { - statusView.pollVoteButton.isHidden = true - statusView.pollVoteActivityIndicatorView.isHidden = true - return - } - - statusView.pollVoteButton.isHidden = isVoting - statusView.pollVoteActivityIndicatorView.isHidden = !isVoting - statusView.pollVoteActivityIndicatorView.startAnimating() - } - .store(in: &disposeBag) - $isVoteButtonEnabled - .assign(to: \.isEnabled, on: statusView.pollVoteButton) - .store(in: &disposeBag) } - private func bindLocation(statusView: StatusView) { - $location - .sink { location in - guard let location = location, !location.isEmpty else { - statusView.locationLabel.isAccessibilityElement = false - return - } - statusView.locationLabel.isAccessibilityElement = true - - if statusView.traitCollection.preferredContentSizeCategory > .extraLarge { - statusView.locationMapPinImageView.image = Asset.ObjectTools.mappin.image - } else { - statusView.locationMapPinImageView.image = Asset.ObjectTools.mappinMini.image - } - statusView.locationLabel.text = location - statusView.locationLabel.accessibilityLabel = location - - statusView.setLocationDisplay() - } - .store(in: &disposeBag) + var cellTopMargin: CGFloat { + switch kind { + case .quote: return .zero + case _ where parentViewModel == nil: return 12 + default: return .zero + } } - private func bindToolbar(statusView: StatusView) { - // platform - $platform - .assign(to: \.platform, on: statusView.toolbar.viewModel) - .store(in: &disposeBag) - // reply - $replyCount - .sink { count in - statusView.toolbar.setupReply(count: count, isEnabled: true) // TODO: - } - .store(in: &disposeBag) - // repost - Publishers.CombineLatest3( - $repostCount, - $isRepost, - $isRepostEnabled - ) - .sink { count, isRepost, isEnabled in - statusView.toolbar.setupRepost(count: count, isEnabled: isEnabled, isHighlighted: isRepost) - } - .store(in: &disposeBag) - // like - Publishers.CombineLatest( - $likeCount, - $isLike - ) - .sink { count, isLike in - statusView.toolbar.setupLike(count: count, isHighlighted: isLike) - } - .store(in: &disposeBag) - // menu - Publishers.CombineLatest4( - $sharePlaintextContent, - $shareStatusURL, - $mediaViewConfigurations, - $isDeletable - ) - .sink { sharePlaintextContent, shareStatusURL, mediaViewConfigurations, isDeletable in - statusView.toolbar.setupMenu(menuContext: .init( - shareText: sharePlaintextContent, - shareLink: shareStatusURL, - displaySaveMediaAction: !mediaViewConfigurations.isEmpty, - displayDeleteAction: isDeletable - )) - } - .store(in: &disposeBag) - } +// var topConversationLinkViewHeight: CGFloat { +// var height: CGFloat = cellTopMargin +// if let statusHeaderViewModel = statusHeaderViewModel { +// height += statusHeaderViewModel.viewSize.height +// height += StatusView.statusHeaderBottomSpacing +// height += parentViewModel?.cellTopMargin ?? 0 +// } +// return height +// } - private func bindReplySettings(statusView: StatusView) { - Publishers.CombineLatest( - $replySettings, - $authorUsername - ) - .sink { replySettings, authorUsername in - guard let replySettings = replySettings else { return } - guard let authorUsername = authorUsername else { return } - switch replySettings { - case .everyone: - return - case .following: - statusView.replySettingBannerView.imageView.image = Asset.Communication.at.image.withRenderingMode(.alwaysTemplate) - statusView.replySettingBannerView.label.text = L10n.Common.Controls.Status.ReplySettings.peopleUserFollowsOrMentionedCanReply("@\(authorUsername)") - case .mentionedUsers: - statusView.replySettingBannerView.imageView.image = Asset.Human.personCheckMini.image.withRenderingMode(.alwaysTemplate) - statusView.replySettingBannerView.label.text = L10n.Common.Controls.Status.ReplySettings.peopleUserMentionedCanReply("@\(authorUsername)") - } - statusView.setReplySettingsDisplay() + var hasToolbar: Bool { + switch kind { + case .timeline, .conversationRoot, .conversationThread: + return true + default: + return false } - .store(in: &disposeBag) } +} + +extension StatusView.ViewModel { + public convenience init?( + feed: Feed, + authContext: AuthContext?, + delegate: StatusViewDelegate?, + viewLayoutFramePublisher: Published.Publisher? + ) { + switch feed.content { + case .status(let object): + self.init( + status: object, + authContext: authContext, + kind: .timeline, + delegate: delegate, + viewLayoutFramePublisher: viewLayoutFramePublisher + ) + default: + assertionFailure("should use other View & ViewModel") + return nil + } + } // end init - private func bindAccessibility(statusView: StatusView) { - let authorAccessibilityLabel = Publishers.CombineLatest( - $header, - $authorName + public convenience init( + status: StatusObject, + authContext: AuthContext?, + kind: Kind = .timeline, + delegate: StatusViewDelegate?, + viewLayoutFramePublisher: Published.Publisher? + ) { + switch status { + case .twitter(let status): + self.init( + status: status, + authContext: authContext, + kind: kind, + delegate: delegate, + parentViewModel: nil, + viewLayoutFramePublisher: viewLayoutFramePublisher + ) + case .mastodon(let status): + self.init( + status: status, + authContext: authContext, + kind: kind, + delegate: delegate, + parentViewModel: nil, + viewLayoutFramePublisher: viewLayoutFramePublisher + ) + } + } // end init +} + +extension StatusView.ViewModel { + public convenience init( + status: TwitterStatus, + authContext: AuthContext?, + kind: Kind, + delegate: StatusViewDelegate?, + parentViewModel: StatusView.ViewModel?, + viewLayoutFramePublisher: Published.Publisher? + ) { + self.init( + status: .twitter(object: status), + author: .twitter(object: status.author), + authContext: authContext, + kind: status.repost != nil ? .repost : kind, + delegate: delegate, + viewLayoutFramePublisher: viewLayoutFramePublisher ) - .map { header, authorName -> String? in - var strings: [String?] = [] - - switch header { - case .none: - break - case .notification(let info): - strings.append(info.textMetaContent.string) - case .repost(let info): - strings.append(info.authorNameMetaContent.string) - } - - strings.append(authorName?.string) + self.parentViewModel = parentViewModel + + if let repost = status.repost { + let _repostViewModel = StatusView.ViewModel( + status: repost, + authContext: authContext, + kind: kind, + delegate: delegate, + parentViewModel: self, + viewLayoutFramePublisher: viewLayoutFramePublisher + ) + repostViewModel = _repostViewModel - return strings.compactMap { $0 }.joined(separator: ", ") + // header - repost + let _statusHeaderViewModel = StatusHeaderView.ViewModel( + image: Asset.Media.repeat.image.withRenderingMode(.alwaysTemplate), + label: { + let name = status.author.name + let userRepostText = L10n.Common.Controls.Status.userRetweeted(name) + let label = PlaintextMetaContent(string: userRepostText) + return label + }() + ) + _statusHeaderViewModel.hasHangingAvatar = { + if kind == .conversationRoot { return true } + return _repostViewModel.hasHangingAvatar + }() + _repostViewModel.statusHeaderViewModel = _statusHeaderViewModel + } + if let quote = status.quote { + quoteViewModel = .init( + status: quote, + authContext: authContext, + kind: .quote, + delegate: delegate, + parentViewModel: self, + viewLayoutFramePublisher: viewLayoutFramePublisher + ) } - let metaAccessibilityLabel = Publishers.CombineLatest( - $timeAgoStyleTimestamp, - $visibility - ) - .map { timestamp, visibility -> String? in - var strings: [String?] = [] - - strings.append(visibility?.accessibilityLabel) - strings.append(timestamp) - - return strings.compactMap { $0 }.joined(separator: ", ") + // reply settings + replySettingBannerViewModel = status.replySettingsTransient + .flatMap { object in Twitter.Entity.V2.Tweet.ReplySettings(rawValue: object.value) } + .flatMap { replaySettings in + ReplySettingBannerView.ViewModel( + replaySettings: replaySettings, + authorUsername: status.author.username + ) + } + + // author + status.author.publisher(for: \.profileImageURL) + .map { _ in status.author.avatarImageURL() } + .assign(to: &$avatarURL) + status.author.publisher(for: \.name) + .map { PlaintextMetaContent(string: $0) } + .assign(to: &$authorName) + status.author.publisher(for: \.username) + .assign(to: &$authorUsernme) + status.author.publisher(for: \.protected) + .assign(to: &$protected) + + // timestamp + switch kind { + case .conversationRoot: + break + default: + timestampLabelViewModel = TimestampLabelView.ViewModel(timestamp: status.createdAt) } - let contentAccessibilityLabel = Publishers.CombineLatest4( - $platform, - $isContentReveal, - $spoilerContent, - $content - ) - .map { platform, isContentReveal, spoilerContent, content -> String? in - var strings: [String?] = [] - switch platform { - case .none: - break - case .twitter: - strings.append(content?.string) - case .mastodon: - if let spoilerContent = spoilerContent?.string { - strings.append(L10n.Accessibility.Common.Status.contentWarning) - strings.append(spoilerContent) - } - if isContentReveal { - strings.append(content?.string) + // content + switch kind { + case .conversationRoot where status.hasMore: + let statusID = status.id + defer { + Task { + try? await self.updateTwitterStatusContent(statusID: statusID) } } - - return strings.compactMap { $0 }.joined(separator: ", ") + fallthrough + default: + let content = TwitterContent(content: status.displayText, urlEntities: status.urlEntities) + let metaContent = TwitterMetaContent.convert( + document: content, + urlMaximumLength: .max, + twitterTextProvider: SwiftTwitterTextProvider(), + useParagraphMark: true + ) + self.content = metaContent } - let mediaAccessibilityLabel = $mediaViewConfigurations - .map { configurations -> String? in - let count = configurations.count - return count > 0 ? L10n.Count.media(count) : nil + // language + status.publisher(for: \.language) + .map { language in + switch language { + case "qam", "qct", "qht", "qme", "qst", "zxx": + return nil + default: + return language + } } + .assign(to: &$language) - let toolbarAccessibilityLabel = Publishers.CombineLatest3( - $platform, - $isRepost, - $isLike - ) - .map { platform, isRepost, isLike -> String? in - var strings: [String?] = [] - - switch platform { - case .none: - break - case .twitter: - if isRepost { - strings.append(L10n.Accessibility.Common.Status.retweeted) + // 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( + authContext: authContext, + poll: .twitter(object: poll) + ) + } + + // location + location = status.locationTransient?.fullName + + // metric + switch kind { + case .conversationRoot: + let _metricViewModel = StatusMetricView.ViewModel(platform: .twitter, timestamp: status.createdAt) + metricViewModel = _metricViewModel + status.publisher(for: \.source) + .assign(to: &_metricViewModel.$source) + status.publisher(for: \.replyCount) + .map { Int($0) } + .assign(to: &_metricViewModel.$replyCount) + status.publisher(for: \.repostCount) + .map { Int($0) } + .assign(to: &_metricViewModel.$repostCount) + status.publisher(for: \.likeCount) + .map { Int($0) } + .assign(to: &_metricViewModel.$likeCount) + default: + break + } + + // toolbar + toolbarViewModel.platform = .twitter + toolbarViewModel.isMyself = isMyself + status.publisher(for: \.replyCount) + .map { Int($0) } + .assign(to: &toolbarViewModel.$replyCount) + status.publisher(for: \.repostCount) + .map { Int($0) } + .assign(to: &toolbarViewModel.$repostCount) + status.publisher(for: \.likeCount) + .map { Int($0) } + .assign(to: &toolbarViewModel.$likeCount) + status.author.publisher(for: \.protected) + .assign(to: &toolbarViewModel.$isReposeRestricted) + if case let .twitter(authenticationContext) = authContext?.authenticationContext { + status.publisher(for: \.likeBy) + .map { users -> Bool in + let ids = users.map { $0.id } + return ids.contains(authenticationContext.userID) } - case .mastodon: - if isRepost { - strings.append(L10n.Accessibility.Common.Status.boosted) + .assign(to: &toolbarViewModel.$isLiked) + status.publisher(for: \.repostBy) + .map { users -> Bool in + let ids = users.map { $0.id } + return ids.contains(authenticationContext.userID) } - } - - if isLike { - strings.append(L10n.Accessibility.Common.Status.liked) - } - - return strings.compactMap { $0 }.joined(separator: ", ") + .assign(to: &toolbarViewModel.$isReposted) + } else { + // do nothing } + } // end init +} + +extension StatusView.ViewModel { + public convenience init( + status: MastodonStatus, + authContext: AuthContext?, + kind: Kind, + delegate: StatusViewDelegate?, + parentViewModel: StatusView.ViewModel?, + viewLayoutFramePublisher: Published.Publisher? + ) { + self.init( + status: .mastodon(object: status), + author: .mastodon(object: status.author), + authContext: authContext, + kind: status.repost != nil ? .repost : kind, + delegate: delegate, + viewLayoutFramePublisher: viewLayoutFramePublisher + ) + self.parentViewModel = parentViewModel - let pollAccessibilityLabel = Publishers.CombineLatest3( - $pollItems, - $pollVoteDescription, - $pollCountdownDescription + if let repost = status.repost { + let _repostViewModel = StatusView.ViewModel( + status: repost, + authContext: authContext, + kind: kind, + delegate: delegate, + parentViewModel: self, + viewLayoutFramePublisher: viewLayoutFramePublisher ) - .map { items, pollVoteDescription, pollCountdownDescription -> String? in - guard !items.isEmpty else { return nil } - guard let managedObjectContext = self.managedObjectContext else { return nil } - - var strings: [String?] = [] - - let ordinalPrefix = L10n.Accessibility.Common.Status.pollOptionOrdinalPrefix - - for (i, item) in items.enumerated() { - switch item { - case .option(let record): - guard let option = record.object(in: managedObjectContext) else { continue } - let number = NSNumber(value: i + 1) - guard let ordinal = StatusView.ViewModel.pollOptionOrdinalNumberFormatter.string(from: number) else { break } - strings.append("\(ordinalPrefix), \(ordinal), \(option.title)") - - if option.isSelected { - strings.append(L10n.Accessibility.VoiceOver.selected) - } - } - } - - strings.append(pollVoteDescription) - pollCountdownDescription.flatMap { strings.append($0) } - - return strings.compactMap { $0 }.joined(separator: ", ") - } + repostViewModel = _repostViewModel + + // header - repost + let _statusHeaderViewModel = StatusHeaderView.ViewModel( + image: Asset.Media.repeat.image.withRenderingMode(.alwaysTemplate), + label: { + let name = status.author.name + let userRepostText = L10n.Common.Controls.Status.userBoosted(name) + let text = MastodonContent(content: userRepostText, emojis: status.author.emojisTransient.asDictionary) + let label = MastodonMetaContent.convert(text: text) + return label + }() + ) + _statusHeaderViewModel.hasHangingAvatar = _repostViewModel.hasHangingAvatar + _repostViewModel.statusHeaderViewModel = _statusHeaderViewModel + } - let groupOne = Publishers.CombineLatest4( - authorAccessibilityLabel, - metaAccessibilityLabel, - contentAccessibilityLabel, - mediaAccessibilityLabel - ) - .map { a, b, c, d -> String? in - return [a, b, c, d] - .compactMap { $0 } - .joined(separator: ", ") + // author + status.author.publisher(for: \.avatar) + .compactMap { $0.flatMap { URL(string: $0) } } + .assign(to: &$avatarURL) + status.author.publisher(for: \.displayName) + .compactMap { _ in status.author.nameMetaContent } + .assign(to: &$authorName) + status.author.publisher(for: \.username) + .map { _ in status.author.acct } + .assign(to: &$authorUsernme) + status.author.publisher(for: \.locked) + .assign(to: &$protected) + + // visibility + visibility = status.visibility + + // timestamp + switch kind { + case .conversationRoot: + break + default: + timestampLabelViewModel = TimestampLabelView.ViewModel(timestamp: status.createdAt) } - let groupTwo = Publishers.CombineLatest3( - pollAccessibilityLabel, - $location, - toolbarAccessibilityLabel - ) - .map { a, b, c -> String? in - return [a, b, c] - .compactMap { $0 } - .joined(separator: ", ") + // spoiler content + if let spoilerText = status.spoilerText, !spoilerText.isEmpty { + do { + let content = MastodonContent(content: spoilerText, emojis: status.emojisTransient.asDictionary) + let metaContent = try MastodonMetaContent.convert(document: content, useParagraphMark: true) + self.spoilerContent = metaContent + } catch { + assertionFailure(error.localizedDescription) + self.spoilerContent = nil + } } - - Publishers.CombineLatest( - groupOne, - groupTwo - ) - .map { a, b -> String in - return [a, b] - .compactMap { $0 } - .joined(separator: ", ") + + // content + do { + let content = MastodonContent(content: status.content, emojis: status.emojisTransient.asDictionary) + let metaContent = try MastodonMetaContent.convert(document: content, useParagraphMark: true) + self.content = metaContent + } catch { + assertionFailure(error.localizedDescription) + self.content = PlaintextMetaContent(string: "") } - .assign(to: &$groupedAccessibilityLabel) + + // language + status.publisher(for: \.language) + .assign(to: &$language) - $groupedAccessibilityLabel - .sink { accessibilityLabel in - statusView.accessibilityLabel = accessibilityLabel - } + // content warning + isContentSensitiveToggled = status.isContentSensitiveToggled + status.publisher(for: \.isContentSensitiveToggled) + .receive(on: DispatchQueue.main) + .assign(to: \.isContentSensitiveToggled, on: self) .store(in: &disposeBag) + // media + mediaViewModels = MediaView.ViewModel.viewModels(from: status) + // poll - $pollItems - .sink { items in - statusView.pollVoteDescriptionLabel.isAccessibilityElement = !items.isEmpty - statusView.pollVoteButton.isAccessibilityElement = !items.isEmpty - } + if let poll = status.poll { + self.pollViewModel = PollView.ViewModel( + authContext: authContext, + poll: .mastodon(object: poll) + ) + } + + // 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) + + // toolbar + toolbarViewModel.platform = .mastodon + toolbarViewModel.isMyself = isMyself + status.publisher(for: \.replyCount) + .map { Int($0) } + .assign(to: &toolbarViewModel.$replyCount) + status.publisher(for: \.repostCount) + .map { Int($0) } + .assign(to: &toolbarViewModel.$repostCount) + status.publisher(for: \.likeCount) + .map { Int($0) } + .assign(to: &toolbarViewModel.$likeCount) + toolbarViewModel.isReposeRestricted = { + switch status.visibility { + case .public: return false + case .unlisted: return false + case .direct: return true + case .private: return true + case ._other: + assertionFailure() + return false + } + }() + if case let .mastodon(authenticationContext) = authContext?.authenticationContext { + status.publisher(for: \.likeBy) + .map { users -> Bool in + let ids = users.map { $0.id } + return ids.contains(authenticationContext.userID) + } + .assign(to: &toolbarViewModel.$isLiked) + status.publisher(for: \.repostBy) + .map { users -> Bool in + let ids = users.map { $0.id } + return ids.contains(authenticationContext.userID) + } + .assign(to: &toolbarViewModel.$isReposted) + } else { + // do nothing + } + + // metric + switch kind { + case .conversationRoot: + let _metricViewModel = StatusMetricView.ViewModel(platform: .mastodon, timestamp: status.createdAt) + metricViewModel = _metricViewModel + status.publisher(for: \.replyCount) + .map { Int($0) } + .assign(to: &_metricViewModel.$replyCount) + status.publisher(for: \.repostCount) + .map { Int($0) } + .assign(to: &_metricViewModel.$repostCount) + status.publisher(for: \.likeCount) + .map { Int($0) } + .assign(to: &_metricViewModel.$likeCount) + default: + break + } } +} +extension StatusView.ViewModel { + public static func prototype( + viewLayoutFramePublisher: Published.Publisher? + ) -> StatusView.ViewModel { + let viewModel = StatusView.ViewModel(viewLayoutFramePublisher: viewLayoutFramePublisher) + + viewModel.addtionalHorizontalMargin = 20 + + viewModel.avatarURL = URL(string: "https://pbs.twimg.com/profile_images/809741368134234112/htSiXXAU_400x400.jpg") + viewModel.authorName = PlaintextMetaContent(string: "Twidere") + viewModel.authorUsernme = "TwidereProject" + viewModel.content = TwitterMetaContent.convert( + document: TwitterContent(content: L10n.Scene.Settings.Display.Preview.thankForUsingTwidereX, urlEntities: []), + urlMaximumLength: 16, + twitterTextProvider: SwiftTwitterTextProvider() + ) + + return viewModel + } } diff --git a/TwidereSDK/Sources/TwidereUI/Content/StatusView.swift b/TwidereSDK/Sources/TwidereUI/Content/StatusView.swift index 18ed6c54..34d45795 100644 --- a/TwidereSDK/Sources/TwidereUI/Content/StatusView.swift +++ b/TwidereSDK/Sources/TwidereUI/Content/StatusView.swift @@ -9,1156 +9,1630 @@ import os.log import Combine import UIKit +import SwiftUI +import Kingfisher import MetaTextKit import MetaTextArea -import TwidereCommon +import MetaLabel import TwidereCore -import NIOPosix public protocol StatusViewDelegate: AnyObject { - func statusView(_ statusView: StatusView, headerDidPressed header: UIView) - - func statusView(_ statusView: StatusView, authorAvatarButtonDidPressed button: AvatarButton) - func statusView(_ statusView: StatusView, quoteStatusView: StatusView, authorAvatarButtonDidPressed button: AvatarButton) - - func statusView(_ statusView: StatusView, expandContentButtonDidPressed button: UIButton) - - func statusView(_ statusView: StatusView, metaTextAreaView: MetaTextAreaView, didSelectMeta meta: Meta) - func statusView(_ statusView: StatusView, quoteStatusView: StatusView, metaTextAreaView: MetaTextAreaView, didSelectMeta meta: Meta) - - func statusView(_ statusView: StatusView, mediaGridContainerView containerView: MediaGridContainerView, didTapMediaView mediaView: MediaView, at index: Int) - func statusView(_ statusView: StatusView, mediaGridContainerView containerView: MediaGridContainerView, toggleContentWarningOverlayViewDisplay contentWarningOverlayView: ContentWarningOverlayView) - func statusView(_ statusView: StatusView, quoteStatusView: StatusView, mediaGridContainerView containerView: MediaGridContainerView, didTapMediaView mediaView: MediaView, at index: Int) - - func statusView(_ statusView: StatusView, pollTableView tableView: UITableView, didSelectRowAt indexPath: IndexPath) - func statusView(_ statusView: StatusView, pollVoteButtonDidPressed button: UIButton) - - func statusView(_ statusView: StatusView, quoteStatusViewDidPressed quoteStatusView: StatusView) - - func statusView(_ statusView: StatusView, statusToolbar: StatusToolbar, actionDidPressed action: StatusToolbar.Action, button: UIButton) - func statusView(_ statusView: StatusView, statusToolbar: StatusToolbar, menuActionDidPressed action: StatusToolbar.MenuAction, menuButton button: UIButton) - - func statusView(_ statusView: StatusView, translateButtonDidPressed button: UIButton) - - // a11y - func statusView(_ statusView: StatusView, accessibilityActivate: Void) -} - -public final class StatusView: UIView { - - private var _disposeBag = Set() // which lifetime same to view scope - public var disposeBag = Set() // clear when reuse - - public weak var delegate: StatusViewDelegate? +// func statusView(_ statusView: StatusView, headerDidPressed header: UIView) - public static let bodyContainerStackViewSpacing: CGFloat = 10 - public static let quoteStatusViewContainerLayoutMargin: CGFloat = 12 - - let logger = Logger(subsystem: "StatusView", category: "View") - - public private(set) var style: Style? - - public private(set) lazy var viewModel: ViewModel = { - let viewModel = ViewModel() - viewModel.bind(statusView: self) - return viewModel - }() - - // container - public let containerStackView: UIStackView = { - let stackView = UIStackView() - stackView.axis = .vertical - stackView.spacing = 8 - return stackView - }() - - // header - public let headerContainerView = UIView() - public let headerIconImageView = UIImageView() - public static var headerTextLabelStyle: TextStyle { .statusHeader } - public let headerTextLabel: MetaLabel = { - let label = MetaLabel(style: .statusHeader) - label.isUserInteractionEnabled = false - return label - }() - // avatar - public let authorAvatarButton = AvatarButton() - - // author - public static var authorNameLabelStyle: TextStyle { .statusAuthorName } - public let authorNameLabel: MetaLabel = { - let label = MetaLabel(style: StatusView.authorNameLabelStyle) - label.accessibilityTraits = .staticText - return label - }() - public let lockImageView: UIImageView = { - let imageView = UIImageView() - imageView.tintColor = .secondaryLabel - imageView.contentMode = .scaleAspectFill - imageView.image = Asset.ObjectTools.lockMiniInline.image.withRenderingMode(.alwaysTemplate) - return imageView - }() - public let authorUsernameLabel = PlainLabel(style: .statusAuthorUsername) - public let visibilityImageView: UIImageView = { - let imageView = UIImageView() - imageView.tintColor = .secondaryLabel - imageView.contentMode = .scaleAspectFill - imageView.image = Asset.ObjectTools.globeMiniInline.image.withRenderingMode(.alwaysTemplate) - return imageView - }() - public let timestampLabel = PlainLabel(style: .statusTimestamp) - + func statusView(_ viewModel: StatusView.ViewModel, userAvatarButtonDidPressed user: UserRecord) + // spoiler - public let spoilerContentTextView: MetaTextAreaView = { - let textView = MetaTextAreaView() - textView.textAttributes = [ - .font: UIFont.preferredFont(forTextStyle: .body), - .foregroundColor: UIColor.label.withAlphaComponent(0.8), - ] - textView.linkAttributes = [ - .font: UIFont.preferredFont(forTextStyle: .body), - .foregroundColor: Asset.Colors.Theme.daylight.color - ] - return textView - }() - - public let expandContentButtonContainer: UIStackView = { - let stackView = UIStackView() - stackView.axis = .horizontal - stackView.alignment = .leading - return stackView - }() - public let expandContentButton: HitTestExpandedButton = { - let button = HitTestExpandedButton(type: .system) - button.setImage(Asset.Editing.ellipsisLarge.image.withRenderingMode(.alwaysTemplate), for: .normal) - button.layer.masksToBounds = true - button.layer.cornerRadius = 10 - button.layer.cornerCurve = .circular - button.backgroundColor = .tertiarySystemFill - return button - }() - - // content - public let contentTextView: MetaTextAreaView = { - let textView = MetaTextAreaView() - textView.textAttributes = [ - .font: UIFont.preferredFont(forTextStyle: .body), - .foregroundColor: UIColor.label.withAlphaComponent(0.8), - ] - textView.linkAttributes = [ - .font: UIFont.preferredFont(forTextStyle: .body), - .foregroundColor: Asset.Colors.Theme.daylight.color - ] - return textView - }() - - // translate - let translateButtonContainer = UIStackView() - public let translateButton: UIButton = { - let button = HitTestExpandedButton(type: .system) - button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 14, weight: .medium)) - button.setTitle(L10n.Common.Controls.Status.Actions.translate, for: .normal) - return button - }() - + func statusView(_ viewModel: StatusView.ViewModel, toggleContentDisplay isReveal: Bool) + + // meta + func statusView(_ viewModel: StatusView.ViewModel, textViewDidSelectMeta meta: Meta?) + // media - public let mediaGridContainerView = MediaGridContainerView() - + func statusView(_ viewModel: StatusView.ViewModel, mediaViewModel: MediaView.ViewModel, action: MediaView.ViewModel.Action) + func statusView(_ viewModel: StatusView.ViewModel, toggleContentWarningOverlayDisplay isReveal: Bool) + // poll - public let pollTableView: UITableView = { - let tableView = UITableView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) - tableView.register(PollOptionTableViewCell.self, forCellReuseIdentifier: String(describing: PollOptionTableViewCell.self)) - tableView.isScrollEnabled = false - tableView.estimatedRowHeight = 36 - tableView.tableFooterView = UIView() - tableView.backgroundColor = .clear - tableView.separatorStyle = .none - return tableView - }() - public var pollTableViewHeightLayoutConstraint: NSLayoutConstraint! - public var pollTableViewDiffableDataSource: UITableViewDiffableDataSource? + 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) - public let pollVoteInfoContainerView = UIStackView() - public let pollVoteDescriptionLabel = PlainLabel(style: .pollVoteDescription) - public let pollVoteButton: HitTestExpandedButton = { - let button = HitTestExpandedButton(type: .system) - button.setTitle(L10n.Common.Controls.Status.Actions.vote, for: .normal) - button.setTitleColor(Asset.Colors.hightLight.color, for: .normal) - button.setTitleColor(.secondaryLabel, for: .disabled) - return button - }() - public let pollVoteActivityIndicatorView: UIActivityIndicatorView = { - let activityIndicatorView = UIActivityIndicatorView(style: .medium) - activityIndicatorView.hidesWhenStopped = true - activityIndicatorView.tintColor = .secondaryLabel - return activityIndicatorView - }() + // repost + func statusView(_ viewModel: StatusView.ViewModel, quoteStatusViewDidPressed quoteViewModel: StatusView.ViewModel) - // quote - public private(set) var quoteStatusView: StatusView? { - didSet { - if let quoteStatusView = quoteStatusView { - quoteStatusView.delegate = self - - let quoteTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer - quoteTapGestureRecognizer.delegate = self - quoteTapGestureRecognizer.addTarget(self, action: #selector(StatusView.quoteStatusViewDidPressed(_:))) - quoteStatusView.addGestureRecognizer(quoteTapGestureRecognizer) - } - } - } - - // location - public let locationContainer: UIStackView = { - let stackView = UIStackView() - stackView.axis = .horizontal - stackView.spacing = 6 - return stackView - }() - public let locationMapPinImageView: UIImageView = { - let imageView = UIImageView() - imageView.tintColor = .secondaryLabel - return imageView - }() - public let locationLabel = PlainLabel(style: .statusLocation) + // metric + func statusView(_ viewModel: StatusView.ViewModel, statusMetricViewModel: StatusMetricView.ViewModel, statusMetricButtonDidPressed action: StatusMetricView.Action) - // metrics - public let metricsDashboardView = StatusMetricsDashboardView() - // toolbar - public let toolbar = StatusToolbar() - - // reply settings - public let replySettingBannerView = ReplySettingBannerView() - - public override init(frame: CGRect) { - super.init(frame: frame) - _init() - } + func statusView(_ viewModel: StatusView.ViewModel, statusToolbarViewModel: StatusToolbarView.ViewModel, statusToolbarButtonDidPressed action: StatusToolbarView.Action) + +// func statusView(_ statusView: StatusView, translateButtonDidPressed button: UIButton) + + func statusView(_ viewModel: StatusView.ViewModel, viewHeightDidChange: Void) + +// // a11y +// func statusView(_ statusView: StatusView, accessibilityActivate: Void) +} + +public struct StatusView: View { - public required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } + static let logger = Logger(subsystem: "StatusView", category: "View") + var logger: Logger { StatusView.logger } - public override func willMove(toWindow newWindow: UIWindow?) { - super.willMove(toWindow: newWindow) - assert(style != nil, "Needs setup style before use") - } + static var statusHeaderBottomSpacing: CGFloat { 6.0 } + static var hangingAvatarButtonDimension: CGFloat { 44.0 } + static var hangingAvatarButtonTrailingSpacing: CGFloat { 10.0 } - deinit { - viewModel.disposeBag.removeAll() - } + @ObservedObject public private(set) var viewModel: ViewModel -} + @Environment(\.dynamicTypeSize) var dynamicTypeSize + @ScaledMetric(relativeTo: .subheadline) private var visibilityIconImageDimension: CGFloat = 16 + @ScaledMetric(relativeTo: .headline) private var inlineAvatarButtonDimension: CGFloat = 20 + @ScaledMetric(relativeTo: .headline) private var lockImageDimension: CGFloat = 16 -extension StatusView { - - public func prepareForReuse() { - disposeBag.removeAll() - viewModel.objects.removeAll() - viewModel.authorAvatarImageURL = nil - authorAvatarButton.avatarImageView.cancelTask() - quoteStatusView?.prepareForReuse() - mediaGridContainerView.prepareForReuse() - if var snapshot = pollTableViewDiffableDataSource?.snapshot() { - snapshot.deleteAllItems() - pollTableViewDiffableDataSource?.applySnapshotUsingReloadData(snapshot) - } - Style.prepareForReuse(statusView: self) + public init(viewModel: StatusView.ViewModel) { + self.viewModel = viewModel } - private func _init() { - containerStackView.translatesAutoresizingMaskIntoConstraints = false - addSubview(containerStackView) - NSLayoutConstraint.activate([ - containerStackView.topAnchor.constraint(equalTo: topAnchor), - containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor), - containerStackView.trailingAnchor.constraint(equalTo: trailingAnchor), - containerStackView.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - - // header - let headerTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer - headerTapGestureRecognizer.addTarget(self, action: #selector(StatusView.headerTapGestureRecognizerHandler(_:))) - headerContainerView.addGestureRecognizer(headerTapGestureRecognizer) - - // avatar button - authorAvatarButton.accessibilityLabel = L10n.Accessibility.Common.Status.authorAvatar - authorAvatarButton.accessibilityHint = L10n.Accessibility.VoiceOver.doubleTapToOpenProfile - authorAvatarButton.addTarget(self, action: #selector(StatusView.authorAvatarButtonDidPressed(_:)), for: .touchUpInside) - - // expand content - expandContentButton.addTarget(self, action: #selector(StatusView.expandContentButtonDidPressed(_:)), for: .touchUpInside) - - // content - contentTextView.delegate = self - - // translateButton - translateButton.addTarget(self, action: #selector(StatusView.translateButtonDidPressed(_:)), for: .touchUpInside) - - // media grid - mediaGridContainerView.delegate = self - - // poll - pollTableView.translatesAutoresizingMaskIntoConstraints = false - pollTableViewHeightLayoutConstraint = pollTableView.heightAnchor.constraint(equalToConstant: 36).priority(.required - 10) - NSLayoutConstraint.activate([ - pollTableViewHeightLayoutConstraint, - ]) - pollTableView.delegate = self - pollVoteButton.addTarget(self, action: #selector(StatusView.pollVoteButtonDidPressed(_:)), for: .touchUpInside) - - // toolbar - toolbar.delegate = self - - // theme - ThemeService.shared.theme - .sink { [weak self] theme in - guard let self = self else { return } - self.update(theme: theme) - } - .store(in: &disposeBag) - } - - public func setup(style: Style) { - guard self.style == nil else { - assertionFailure("Should only setup once") - return + public var body: some View { + VStack(spacing: .zero) { + if let repostViewModel = viewModel.repostViewModel { + // cell top margin + Color.clear.frame(height: viewModel.cellTopMargin) + // header + if let statusHeaderViewModel = repostViewModel.statusHeaderViewModel { + StatusHeaderView(viewModel: statusHeaderViewModel) + Color.clear.frame(height: StatusView.statusHeaderBottomSpacing) + } + // post + StatusView(viewModel: repostViewModel) + } else { + // cell top margin + Color.clear.frame(height: viewModel.cellTopMargin) + .overlay { + Group { + // top conversation link + switch viewModel.kind { + case .conversationThread, .conversationRoot: + HStack(spacing: .zero) { + VStack(alignment: .center, spacing: .zero) { + Rectangle() + .foregroundColor(Color(uiColor: .separator)) + .background(.clear) + .frame(width: 1) + .frame(maxHeight: .infinity) + .opacity(viewModel.isTopConversationLinkLineViewDisplay ? 1 : 0) + } + .frame(width: Self.hangingAvatarButtonDimension) // avatar button width + .frame(maxHeight: .infinity) + Spacer() + } + default: + EmptyView() + } + } + } + HStack(alignment: .top, spacing: .zero) { + if viewModel.hasHangingAvatar { + avatarButton + .padding(.trailing, Self.hangingAvatarButtonTrailingSpacing) + } + let contentSpacing: CGFloat = 4 + VStack(spacing: contentSpacing) { + // authorView + authorView + // spoiler content (Mastodon) + if viewModel.spoilerContent != nil { + spoilerContentView + if !viewModel.isContentEmpty { + Button { + // force to trigger view update without animation + withAnimation(.none) { + viewModel.isContentSensitiveToggled.toggle() + } + viewModel.delegate?.statusView(viewModel, toggleContentDisplay: !viewModel.isContentReveal) + } label: { + HStack { + Image(uiImage: Asset.Editing.ellipsisLarge.image.withRenderingMode(.alwaysTemplate)) + .background(Color(uiColor: .tertiarySystemFill)) + .clipShape(Capsule()) + Spacer() + } + } + .buttonStyle(.borderless) + .id(UUID()) // fix animation issue + } + } + // content + if viewModel.isContentReveal { + contentView + + if viewModel.isTranslateButtonDisplay { + translateButton + } + } + // media + if !viewModel.mediaViewModels.isEmpty { + MediaGridContainerView( + viewModels: viewModel.mediaViewModels, + idealWidth: viewModel.contentWidth, + idealHeight: 280, + handler: { mediaViewModel, action in + viewModel.delegate?.statusView(viewModel, mediaViewModel: mediaViewModel, action: action) + } + ) + .overlay { + if viewModel.isMediaContentWarningOverlayToggleButtonDisplay { + ContentWarningOverlayView(isReveal: viewModel.isMediaContentWarningOverlayReveal) { + viewModel.delegate?.statusView(viewModel, toggleContentWarningOverlayDisplay: !viewModel.isMediaContentWarningOverlayReveal) + } + .cornerRadius(MediaGridContainerView.cornerRadius) + } + } + } + // poll + if let pollViewModel = viewModel.pollViewModel { + PollView( + viewModel: pollViewModel, + selectAction: { optionViewModel in + viewModel.delegate?.statusView(viewModel, pollViewModel: pollViewModel, pollOptionDidSelectForViewModel: optionViewModel) + }, voteAction: { pollViewModel in + viewModel.delegate?.statusView(viewModel, pollVoteActionForViewModel: pollViewModel) + } + ) + .onAppear { + viewModel.delegate?.statusView(viewModel, pollUpdateIfNeedsForViewModel: pollViewModel) + } + } + // quote + if let quoteViewModel = viewModel.quoteViewModel { + StatusView(viewModel: quoteViewModel) + .background { + Color(uiColor: .label.withAlphaComponent(0.04)) + } + .cornerRadius(12) + .contentShape(Rectangle()) + .onTapGesture { + viewModel.delegate?.statusView(viewModel, quoteStatusViewDidPressed: quoteViewModel) + } + } + // location (inline) + if let location = viewModel.location { + HStack { + Image(uiImage: Asset.ObjectTools.mappinMini.image.withRenderingMode(.alwaysTemplate)) + Text(location) + Spacer() + } + .foregroundColor(.secondary) + .font(Font(TextStyle.statusLocation.font)) + .frame(alignment: .leading) + } + // metric + if let metricViewModel = viewModel.metricViewModel { + StatusMetricView(viewModel: metricViewModel) { action in + // TODO: + } + .padding(.vertical, 8) + } + // toolbar + if viewModel.hasToolbar { + VStack(spacing: .zero) { + toolbarView + .overlay(alignment: .top) { + switch viewModel.kind { + case .conversationRoot: + // toolbar top divider + VStack(spacing: .zero) { + Color.clear + .frame(height: 1) + Divider() + .frame(width: viewModel.viewLayoutFrame.safeAreaLayoutFrame.width) + .fixedSize() + Spacer() + } + default: + EmptyView() + } + } + if viewModel.kind == .conversationRoot, + let replySettingBannerViewModel = viewModel.replySettingBannerViewModel, + !replySettingBannerViewModel.shouldHidden + { + HStack { + ReplySettingBannerView(viewModel: replySettingBannerViewModel) + Spacer() + } + .background { + Color(uiColor: Asset.Colors.hightLight.color.withAlphaComponent(0.6)) + .frame(width: viewModel.viewLayoutFrame.safeAreaLayoutFrame.width) + .overlay(alignment: .top) { + // reply settings banner top divider + VStack(spacing: .zero) { + Divider() + } + } + } + } + } // end VStack + } + } // end VStack + .padding(.top, viewModel.margin) // container margin + .padding(.horizontal, viewModel.margin) // container margin + .padding(.bottom, viewModel.hasToolbar ? .zero : viewModel.margin) // container margin + .frame(width: viewModel.containerWidth) + .overlay(alignment: .bottom) { + switch viewModel.kind { + case .timeline, .repost, .conversationThread: + VStack(spacing: .zero) { + Spacer() + Divider() + Color.clear + .frame(height: 1) + } + case .conversationRoot: + // cell bottom divider + VStack(spacing: .zero) { + Spacer() + Divider() + .frame(width: viewModel.viewLayoutFrame.safeAreaLayoutFrame.width) + .fixedSize() + } + default: + EmptyView() + } + } + } // end HStack + .overlay { + // bottom conversation link + HStack(alignment: .top, spacing: .zero) { + VStack(alignment: .center, spacing: 0) { + Color.clear + .frame(width: StatusView.hangingAvatarButtonDimension, height: StatusView.hangingAvatarButtonDimension) + Rectangle() + .foregroundColor(Color(uiColor: .separator)) + .background(.clear) + .frame(width: 1) + .opacity(viewModel.isBottomConversationLinkLineViewDisplay ? 1 : 0) + } + Spacer() + } // end HStack + } // end overlay + } // end if … else … + } // end VStack + .onReceive(viewModel.$isContentSensitiveToggled) { _ in + // trigger tableView reload to update the cell height + viewModel.delegate?.statusView(viewModel, viewHeightDidChange: Void()) } - self.style = style - style.layout(statusView: self) - Style.prepareForReuse(statusView: self) } - -} -extension StatusView { - public enum Style { - case inline // for timeline - case plain // for thread - case quote // for quote - case composeReply // for compose - - func layout(statusView: StatusView) { - switch self { - case .inline: layoutInline(statusView: statusView) - case .plain: layoutPlain(statusView: statusView) - case .quote: layoutQuote(statusView: statusView) - case .composeReply: layoutComposeReply(statusView: statusView) - } - } - - static func prepareForReuse(statusView: StatusView) { - statusView.headerContainerView.isHidden = true - statusView.lockImageView.isHidden = true - statusView.visibilityImageView.isHidden = true - statusView.spoilerContentTextView.isHidden = true - statusView.expandContentButtonContainer.isHidden = true - statusView.translateButtonContainer.isHidden = true - statusView.mediaGridContainerView.isHidden = true - statusView.pollTableView.isHidden = true - statusView.pollVoteInfoContainerView.isHidden = true - statusView.pollVoteActivityIndicatorView.isHidden = true - statusView.quoteStatusView?.isHidden = true - statusView.locationContainer.isHidden = true - statusView.replySettingBannerView.isHidden = true - } - } } -extension StatusView.Style { - - private func layoutInline(statusView: StatusView) { - // container: V - [ header container | body container ] - - // header container: H - [ icon | label ] - statusView.containerStackView.addArrangedSubview(statusView.headerContainerView) - statusView.headerIconImageView.translatesAutoresizingMaskIntoConstraints = false - statusView.headerTextLabel.translatesAutoresizingMaskIntoConstraints = false - statusView.headerContainerView.addSubview(statusView.headerIconImageView) - statusView.headerContainerView.addSubview(statusView.headerTextLabel) - NSLayoutConstraint.activate([ - statusView.headerTextLabel.topAnchor.constraint(equalTo: statusView.headerContainerView.topAnchor), - statusView.headerTextLabel.bottomAnchor.constraint(equalTo: statusView.headerContainerView.bottomAnchor), - statusView.headerTextLabel.trailingAnchor.constraint(equalTo: statusView.headerContainerView.trailingAnchor), - statusView.headerIconImageView.centerYAnchor.constraint(equalTo: statusView.headerTextLabel.centerYAnchor), - statusView.headerIconImageView.heightAnchor.constraint(equalTo: statusView.headerTextLabel.heightAnchor, multiplier: 1.0).priority(.required - 1), - statusView.headerIconImageView.widthAnchor.constraint(equalTo: statusView.headerIconImageView.heightAnchor, multiplier: 1.0).priority(.required - 1), - statusView.headerTextLabel.leadingAnchor.constraint(equalTo: statusView.headerIconImageView.trailingAnchor, constant: 4), - // align to author name below - ]) - statusView.headerTextLabel.setContentHuggingPriority(.required - 10, for: .vertical) - statusView.headerIconImageView.setContentHuggingPriority(.defaultLow, for: .vertical) - statusView.headerIconImageView.setContentHuggingPriority(.defaultLow, for: .horizontal) - - // body container: H - [ authorAvatarButton | content container ] - let bodyContainerStackView = UIStackView() - bodyContainerStackView.axis = .horizontal - bodyContainerStackView.spacing = StatusView.bodyContainerStackViewSpacing - bodyContainerStackView.alignment = .top - statusView.containerStackView.addArrangedSubview(bodyContainerStackView) - - // authorAvatarButton - let authorAvatarButtonSize = CGSize(width: 44, height: 44) - statusView.authorAvatarButton.size = authorAvatarButtonSize - statusView.authorAvatarButton.avatarImageView.imageViewSize = authorAvatarButtonSize - statusView.authorAvatarButton.translatesAutoresizingMaskIntoConstraints = false - bodyContainerStackView.addArrangedSubview(statusView.authorAvatarButton) - NSLayoutConstraint.activate([ - statusView.authorAvatarButton.widthAnchor.constraint(equalToConstant: authorAvatarButtonSize.width).priority(.required - 1), - statusView.authorAvatarButton.heightAnchor.constraint(equalToConstant: authorAvatarButtonSize.height).priority(.required - 1), - ]) - - // content container: V - [ author content | contentTextView | mediaGridContainerView | quoteStatusView | … | location content | toolbar ] - let contentContainerView = UIStackView() - contentContainerView.axis = .vertical - contentContainerView.spacing = 10 - bodyContainerStackView.addArrangedSubview(contentContainerView) - - // author content: H - [ authorNameLabel | lockImageView | authorUsernameLabel | padding | visibilityImageView (for Mastodon) | timestampLabel ] - let authorContentStackView = UIStackView() - authorContentStackView.axis = .horizontal - authorContentStackView.spacing = 6 - contentContainerView.addArrangedSubview(authorContentStackView) - contentContainerView.setCustomSpacing(4, after: authorContentStackView) - UIContentSizeCategory.publisher - .sink { category in - authorContentStackView.axis = category > .accessibilityLarge ? .vertical : .horizontal - authorContentStackView.alignment = category > .accessibilityLarge ? .leading : .fill +extension StatusView { + var authorView: some View { + HStack(alignment: .center) { + if !viewModel.hasHangingAvatar { + // avatar + avatarButton } - .store(in: &statusView._disposeBag) - - // authorNameLabel - authorContentStackView.addArrangedSubview(statusView.authorNameLabel) - statusView.authorNameLabel.setContentCompressionResistancePriority(.required - 10, for: .horizontal) - // lockImageView - statusView.lockImageView.translatesAutoresizingMaskIntoConstraints = false - authorContentStackView.addArrangedSubview(statusView.lockImageView) - // authorUsernameLabel - authorContentStackView.addArrangedSubview(statusView.authorUsernameLabel) - NSLayoutConstraint.activate([ - statusView.lockImageView.heightAnchor.constraint(equalTo: statusView.authorUsernameLabel.heightAnchor).priority(.required - 10), - ]) - statusView.lockImageView.setContentHuggingPriority(.defaultLow, for: .horizontal) - statusView.lockImageView.setContentCompressionResistancePriority(.required - 10, for: .horizontal) - statusView.authorUsernameLabel.setContentCompressionResistancePriority(.required - 11, for: .horizontal) - // padding - authorContentStackView.addArrangedSubview(UIView()) - // visibilityImageView - authorContentStackView.addArrangedSubview(statusView.visibilityImageView) - statusView.visibilityImageView.setContentHuggingPriority(.required - 9, for: .horizontal) - statusView.visibilityImageView.setContentCompressionResistancePriority(.required - 9, for: .horizontal) - // timestampLabel - authorContentStackView.addArrangedSubview(statusView.timestampLabel) - statusView.timestampLabel.setContentHuggingPriority(.required - 8, for: .horizontal) - statusView.timestampLabel.setContentCompressionResistancePriority(.required - 8, for: .horizontal) - - // set header label align to author name - NSLayoutConstraint.activate([ - statusView.headerTextLabel.leadingAnchor.constraint(equalTo: statusView.authorNameLabel.leadingAnchor), - ]) - - // spoilerContentTextView - contentContainerView.addArrangedSubview(statusView.spoilerContentTextView) - statusView.spoilerContentTextView.setContentHuggingPriority(.required - 9, for: .vertical) - statusView.spoilerContentTextView.setContentCompressionResistancePriority(.required - 10, for: .vertical) - - contentContainerView.addArrangedSubview(statusView.expandContentButtonContainer) - statusView.expandContentButton.translatesAutoresizingMaskIntoConstraints = false - statusView.expandContentButtonContainer.addArrangedSubview(statusView.expandContentButton) - statusView.expandContentButtonContainer.addArrangedSubview(UIView()) - - // contentTextView - // statusView.contentTextView.translatesAutoresizingMaskIntoConstraints = false - contentContainerView.addArrangedSubview(statusView.contentTextView) - statusView.contentTextView.setContentHuggingPriority(.required - 10, for: .vertical) - - // mediaGridContainerView - statusView.mediaGridContainerView.translatesAutoresizingMaskIntoConstraints = false - contentContainerView.addArrangedSubview(statusView.mediaGridContainerView) - - // pollTableView - contentContainerView.addArrangedSubview(statusView.pollTableView) - - statusView.pollVoteInfoContainerView.axis = .horizontal - statusView.pollVoteInfoContainerView.addArrangedSubview(statusView.pollVoteDescriptionLabel) - statusView.pollVoteInfoContainerView.addArrangedSubview(UIView()) // spacer - statusView.pollVoteInfoContainerView.addArrangedSubview(statusView.pollVoteButton) - statusView.pollVoteButton.setContentHuggingPriority(.required - 10, for: .horizontal) - statusView.pollVoteButton.setContentCompressionResistancePriority(.required - 9, for: .horizontal) - statusView.pollVoteInfoContainerView.addArrangedSubview(statusView.pollVoteActivityIndicatorView) - statusView.pollVoteInfoContainerView.setContentHuggingPriority(.required - 11, for: .horizontal) - statusView.pollVoteInfoContainerView.setContentCompressionResistancePriority(.required - 10, for: .horizontal) - contentContainerView.addArrangedSubview(statusView.pollVoteInfoContainerView) - - // quoteStatusView - let quoteStatusView = StatusView() - quoteStatusView.setup(style: .quote) - statusView.quoteStatusView = quoteStatusView - contentContainerView.addArrangedSubview(quoteStatusView) - - // location content: H - [ locationMapPinImageView | locationLabel ] - contentContainerView.addArrangedSubview(statusView.locationContainer) - - statusView.locationMapPinImageView.translatesAutoresizingMaskIntoConstraints = false - statusView.locationLabel.translatesAutoresizingMaskIntoConstraints = false - - // locationMapPinImageView - statusView.locationContainer.addArrangedSubview(statusView.locationMapPinImageView) - // locationLabel - statusView.locationContainer.addArrangedSubview(statusView.locationLabel) - - NSLayoutConstraint.activate([ - statusView.locationMapPinImageView.heightAnchor.constraint(equalTo: statusView.locationLabel.heightAnchor, multiplier: 1.0).priority(.required - 1), - statusView.locationMapPinImageView.widthAnchor.constraint(equalTo: statusView.locationMapPinImageView.heightAnchor, multiplier: 1.0).priority(.required - 1), - ]) - statusView.locationLabel.setContentHuggingPriority(.required - 10, for: .vertical) - statusView.locationMapPinImageView.setContentHuggingPriority(.defaultLow, for: .vertical) - statusView.locationMapPinImageView.setContentHuggingPriority(.defaultLow, for: .horizontal) - - // toolbar - contentContainerView.addArrangedSubview(statusView.toolbar) - statusView.toolbar.setContentHuggingPriority(.required - 9, for: .vertical) - } - - private func layoutPlain(statusView: StatusView) { - // container: V - [ header container | author container | contentTextView | mediaGridContainerView | quoteStatusView | location content | toolbar ] - - // header container: H - [ icon | label ] - statusView.containerStackView.addArrangedSubview(statusView.headerContainerView) - statusView.headerIconImageView.translatesAutoresizingMaskIntoConstraints = false - statusView.headerTextLabel.translatesAutoresizingMaskIntoConstraints = false - statusView.headerContainerView.addSubview(statusView.headerIconImageView) - statusView.headerContainerView.addSubview(statusView.headerTextLabel) - NSLayoutConstraint.activate([ - statusView.headerTextLabel.topAnchor.constraint(equalTo: statusView.headerContainerView.topAnchor), - statusView.headerTextLabel.bottomAnchor.constraint(equalTo: statusView.headerContainerView.bottomAnchor), - statusView.headerTextLabel.trailingAnchor.constraint(equalTo: statusView.headerContainerView.trailingAnchor), - statusView.headerIconImageView.centerYAnchor.constraint(equalTo: statusView.headerTextLabel.centerYAnchor), - statusView.headerIconImageView.heightAnchor.constraint(equalTo: statusView.headerTextLabel.heightAnchor, multiplier: 1.0).priority(.required - 1), - statusView.headerIconImageView.widthAnchor.constraint(equalTo: statusView.headerIconImageView.heightAnchor, multiplier: 1.0).priority(.required - 1), - statusView.headerTextLabel.leadingAnchor.constraint(equalTo: statusView.headerIconImageView.trailingAnchor, constant: 4), - // align to author name below - ]) - statusView.headerTextLabel.setContentHuggingPriority(.required - 10, for: .vertical) - statusView.headerIconImageView.setContentHuggingPriority(.defaultLow, for: .vertical) - statusView.headerIconImageView.setContentHuggingPriority(.defaultLow, for: .horizontal) - statusView.headerContainerView.isHidden = true - - // author content: H - [ authorAvatarButton | author info content ] - let authorContentStackView = UIStackView() - let authorContentStackViewSpacing: CGFloat = 10 - authorContentStackView.axis = .horizontal - authorContentStackView.spacing = authorContentStackViewSpacing - statusView.containerStackView.addArrangedSubview(authorContentStackView) - - // authorAvatarButton - let authorAvatarButtonSize = CGSize(width: 44, height: 44) - statusView.authorAvatarButton.size = authorAvatarButtonSize - statusView.authorAvatarButton.avatarImageView.imageViewSize = authorAvatarButtonSize - statusView.authorAvatarButton.translatesAutoresizingMaskIntoConstraints = false - authorContentStackView.addArrangedSubview(statusView.authorAvatarButton) - let authorAvatarButtonWidthFixLayoutConstraint = statusView.authorAvatarButton.widthAnchor.constraint(equalToConstant: authorAvatarButtonSize.width).priority(.required - 1) - NSLayoutConstraint.activate([ - authorAvatarButtonWidthFixLayoutConstraint, - statusView.authorAvatarButton.heightAnchor.constraint(equalTo: statusView.authorAvatarButton.widthAnchor, multiplier: 1.0).priority(.required - 1), - ]) - - // author info content: V - [ author info headline content | author info sub-headline content ] - let authorInfoContentStackView = UIStackView() - authorInfoContentStackView.axis = .vertical - authorContentStackView.addArrangedSubview(authorInfoContentStackView) - - // author info headline content: H - [ authorNameLabel | lockImageView | padding | visibilityImageView (for Mastodon) ] - let authorInfoHeadlineContentStackView = UIStackView() - authorInfoHeadlineContentStackView.axis = .horizontal - authorInfoHeadlineContentStackView.spacing = 2 - authorInfoContentStackView.addArrangedSubview(authorInfoHeadlineContentStackView) - - // authorNameLabel - authorInfoHeadlineContentStackView.addArrangedSubview(statusView.authorNameLabel) - statusView.authorNameLabel.setContentCompressionResistancePriority(.required - 10, for: .horizontal) - // lockImageView - statusView.lockImageView.translatesAutoresizingMaskIntoConstraints = false - authorInfoHeadlineContentStackView.addArrangedSubview(statusView.lockImageView) - NSLayoutConstraint.activate([ - statusView.lockImageView.heightAnchor.constraint(equalTo: statusView.authorNameLabel.heightAnchor).priority(.required - 10), - ]) - statusView.lockImageView.setContentHuggingPriority(.defaultLow, for: .horizontal) - statusView.lockImageView.setContentCompressionResistancePriority(.required - 10, for: .horizontal) - // padding - authorInfoHeadlineContentStackView.addArrangedSubview(UIView()) - // visibilityImageView - authorInfoHeadlineContentStackView.addArrangedSubview(statusView.visibilityImageView) - statusView.visibilityImageView.setContentHuggingPriority(.required - 9, for: .horizontal) - statusView.visibilityImageView.setContentCompressionResistancePriority(.required - 9, for: .horizontal) - - // set header label align to author name - NSLayoutConstraint.activate([ - statusView.headerTextLabel.leadingAnchor.constraint(equalTo: statusView.authorAvatarButton.trailingAnchor, constant: authorContentStackViewSpacing), - ]) - - // author info sub-headline content: H - [ authorUsernameLabel ] - let authorInfoSubHeadlineContentStackView = UIStackView() - authorInfoSubHeadlineContentStackView.axis = .horizontal - authorInfoContentStackView.addArrangedSubview(authorInfoSubHeadlineContentStackView) - - UIContentSizeCategory.publisher - .sink { category in - if category >= .extraExtraLarge { - authorContentStackView.axis = .vertical - authorContentStackView.alignment = .leading // set leading + // info + let infoLayout: AnyLayout = { + if dynamicTypeSize < .accessibility1 { + return AnyLayout(HStackLayout(alignment: .center, spacing: 6)) } else { - authorContentStackView.axis = .horizontal - authorContentStackView.alignment = .fill // restore default + return AnyLayout(VStackLayout(alignment: .leading, spacing: .zero)) } - } - .store(in: &statusView._disposeBag) - - // authorUsernameLabel - authorInfoSubHeadlineContentStackView.addArrangedSubview(statusView.authorUsernameLabel) - - // spoilerContentTextView - statusView.containerStackView.addArrangedSubview(statusView.spoilerContentTextView) - statusView.spoilerContentTextView.setContentCompressionResistancePriority(.required - 10, for: .vertical) - statusView.spoilerContentTextView.setContentHuggingPriority(.required - 9, for: .vertical) - - statusView.containerStackView.addArrangedSubview(statusView.expandContentButtonContainer) - statusView.expandContentButton.translatesAutoresizingMaskIntoConstraints = false - statusView.expandContentButtonContainer.addArrangedSubview(statusView.expandContentButton) - statusView.expandContentButtonContainer.addArrangedSubview(UIView()) - - // contentTextView - statusView.containerStackView.addArrangedSubview(statusView.contentTextView) - - // translateButtonContainer: H - [ translateButton | (spacer) ] - statusView.translateButtonContainer.axis = .horizontal - statusView.containerStackView.addArrangedSubview(statusView.translateButtonContainer) - - statusView.translateButtonContainer.addArrangedSubview(statusView.translateButton) - statusView.translateButtonContainer.addArrangedSubview(UIView()) - statusView.translateButton.setContentHuggingPriority(.required - 1, for: .vertical) - statusView.translateButton.setContentCompressionResistancePriority(.required - 1, for: .vertical) - - // mediaGridContainerView - statusView.containerStackView.addArrangedSubview(statusView.mediaGridContainerView) - - // pollTableView - statusView.containerStackView.addArrangedSubview(statusView.pollTableView) - - statusView.pollVoteInfoContainerView.axis = .horizontal - statusView.pollVoteInfoContainerView.addArrangedSubview(statusView.pollVoteDescriptionLabel) - statusView.pollVoteInfoContainerView.addArrangedSubview(UIView()) // spacer - statusView.pollVoteInfoContainerView.addArrangedSubview(statusView.pollVoteButton) - statusView.pollVoteButton.setContentHuggingPriority(.required - 10, for: .horizontal) - statusView.pollVoteButton.setContentCompressionResistancePriority(.required - 9, for: .horizontal) - statusView.pollVoteInfoContainerView.addArrangedSubview(statusView.pollVoteActivityIndicatorView) - statusView.pollVoteInfoContainerView.setContentHuggingPriority(.required - 11, for: .horizontal) - statusView.pollVoteInfoContainerView.setContentCompressionResistancePriority(.required - 10, for: .horizontal) - statusView.containerStackView.addArrangedSubview(statusView.pollVoteInfoContainerView) - - // quoteStatusView - let quoteStatusView = StatusView() - quoteStatusView.setup(style: .quote) - statusView.quoteStatusView = quoteStatusView - statusView.containerStackView.addArrangedSubview(quoteStatusView) - - // location content: H - [ padding | locationMapPinImageView | locationLabel | padding ] - statusView.containerStackView.addArrangedSubview(statusView.locationContainer) - - statusView.locationMapPinImageView.translatesAutoresizingMaskIntoConstraints = false - statusView.locationLabel.translatesAutoresizingMaskIntoConstraints = false - - // locationLeadingPadding - let locationLeadingPadding = UIView() - locationLeadingPadding.translatesAutoresizingMaskIntoConstraints = false - statusView.locationContainer.addArrangedSubview(locationLeadingPadding) - // locationMapPinImageView - statusView.locationContainer.addArrangedSubview(statusView.locationMapPinImageView) - // locationLabel - statusView.locationContainer.addArrangedSubview(statusView.locationLabel) - // locationTrailingPadding - let locationTrailingPadding = UIView() - locationTrailingPadding.translatesAutoresizingMaskIntoConstraints = false - statusView.locationContainer.addArrangedSubview(locationTrailingPadding) - - // center alignment - NSLayoutConstraint.activate([ - locationLeadingPadding.widthAnchor.constraint(equalTo: locationTrailingPadding.widthAnchor).priority(.defaultHigh), - ]) - - NSLayoutConstraint.activate([ - statusView.locationMapPinImageView.heightAnchor.constraint(equalTo: statusView.locationLabel.heightAnchor, multiplier: 1.0).priority(.required - 1), - statusView.locationMapPinImageView.widthAnchor.constraint(equalTo: statusView.locationMapPinImageView.heightAnchor, multiplier: 1.0).priority(.required - 1), - ]) - statusView.locationLabel.setContentHuggingPriority(.required - 10, for: .vertical) - statusView.locationMapPinImageView.setContentHuggingPriority(.defaultLow, for: .vertical) - statusView.locationMapPinImageView.setContentHuggingPriority(.defaultLow, for: .horizontal) - - // metrics - statusView.containerStackView.addArrangedSubview(statusView.metricsDashboardView) - - UIContentSizeCategory.publisher - .sink { category in - if category >= .extraExtraLarge { - statusView.metricsDashboardView.metaContainer.axis = .vertical - } else { - statusView.metricsDashboardView.metaContainer.axis = .horizontal + }() + infoLayout { + let nameLayout: AnyLayout = { + switch viewModel.kind { + case .conversationRoot: + return AnyLayout(VStackLayout(alignment: .leading, spacing: .zero)) + default: + if dynamicTypeSize < .accessibility1 { + return AnyLayout(HStackLayout(alignment: .bottom, spacing: 6)) + } else { + return AnyLayout(VStackLayout(alignment: .leading, spacing: .zero)) + } + } + }() + nameLayout { + HStack(spacing: 4) { + // name + LabelRepresentable( + metaContent: viewModel.authorName, + textStyle: .statusAuthorName, + setupLabel: { label in + label.setContentHuggingPriority(.defaultHigh, for: .horizontal) + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + } + ) + .fixedSize(horizontal: false, vertical: true) + // lock + if viewModel.protected { + Image(uiImage: Asset.ObjectTools.lockMini.image.withRenderingMode(.alwaysTemplate)) + .resizable() + .frame(width: lockImageDimension, height: lockImageDimension) + .foregroundColor(.secondary) + } + } + .layoutPriority(0.618) + // username + LabelRepresentable( + metaContent: PlaintextMetaContent(string: "@" + viewModel.authorUsernme), + textStyle: .statusAuthorUsername, + setupLabel: { label in + label.setContentHuggingPriority(.defaultHigh, for: .horizontal) + label.setContentCompressionResistancePriority(.defaultLow - 100, for: .horizontal) + } + ) + .fixedSize(horizontal: false, vertical: true) + .layoutPriority(0.382) + } // end nameLayout + .frame(alignment: .leading) + Spacer() + HStack(spacing: 6) { + // mastodon visibility + if let visibilityIconImage = viewModel.visibilityIconImage { + let dimension = visibilityIconImageDimension + let alignment: Alignment = viewModel.kind == .conversationRoot ? .top : .center + Color.clear + .frame(width: dimension) + .overlay(alignment: alignment) { + VectorImageView(image: visibilityIconImage, tintColor: TextStyle.statusTimestamp.textColor) + .frame(width: dimension, height: dimension) + } + } + // timestamp + if let timestampLabelViewModel = viewModel.timestampLabelViewModel { + TimestampLabelView(viewModel: timestampLabelViewModel) + } } + } // end infoLayout + // add spacer to make the infoLayout leading align + if dynamicTypeSize < .accessibility1 { + // do nothing + } else { + Spacer() } - .store(in: &statusView._disposeBag) - - // toolbar - statusView.containerStackView.addArrangedSubview(statusView.toolbar) - statusView.toolbar.setContentHuggingPriority(.required - 9, for: .vertical) - - // reply settings - statusView.containerStackView.addArrangedSubview(statusView.replySettingBannerView) - } - - private func layoutQuote(statusView: StatusView) { - // container: V - [ body container ] - // set `isLayoutMarginsRelativeArrangement` not works with AutoLayout (priority issue) - // add constraint to workaround - statusView.containerStackView.backgroundColor = .secondarySystemBackground - statusView.containerStackView.layer.masksToBounds = true - statusView.containerStackView.layer.cornerCurve = .continuous - statusView.containerStackView.layer.cornerRadius = 12 - - // body container: V - [ author content | content container | contentTextView | mediaGridContainerView ] - let bodyContainerStackView = UIStackView() - bodyContainerStackView.axis = .vertical - bodyContainerStackView.spacing = StatusView.bodyContainerStackViewSpacing - bodyContainerStackView.translatesAutoresizingMaskIntoConstraints = false - statusView.containerStackView.addSubview(bodyContainerStackView) - NSLayoutConstraint.activate([ - bodyContainerStackView.topAnchor.constraint(equalTo: statusView.containerStackView.topAnchor, constant: StatusView.quoteStatusViewContainerLayoutMargin).priority(.required - 1), - bodyContainerStackView.leadingAnchor.constraint(equalTo: statusView.containerStackView.leadingAnchor, constant: StatusView.quoteStatusViewContainerLayoutMargin).priority(.required - 1), - statusView.containerStackView.trailingAnchor.constraint(equalTo: bodyContainerStackView.trailingAnchor, constant: StatusView.quoteStatusViewContainerLayoutMargin).priority(.required - 1), - statusView.containerStackView.bottomAnchor.constraint(equalTo: bodyContainerStackView.bottomAnchor, constant: StatusView.quoteStatusViewContainerLayoutMargin).priority(.required - 1), - ]) - - // author content: H - [ authorAvatarButton | authorNameLabel | lockImageView | authorUsernameLabel | padding ] - let authorContentStackView = UIStackView() - authorContentStackView.axis = .horizontal - bodyContainerStackView.alignment = .top - authorContentStackView.spacing = 6 - bodyContainerStackView.addArrangedSubview(authorContentStackView) - bodyContainerStackView.setCustomSpacing(4, after: authorContentStackView) - - // authorAvatarButton - statusView.authorAvatarButton.translatesAutoresizingMaskIntoConstraints = false - authorContentStackView.addArrangedSubview(statusView.authorAvatarButton) - // authorNameLabel - authorContentStackView.addArrangedSubview(statusView.authorNameLabel) - statusView.authorNameLabel.setContentCompressionResistancePriority(.required - 10, for: .horizontal) - // lockImageView - statusView.lockImageView.translatesAutoresizingMaskIntoConstraints = false - authorContentStackView.addArrangedSubview(statusView.lockImageView) - // authorUsernameLabel - authorContentStackView.addArrangedSubview(statusView.authorUsernameLabel) - NSLayoutConstraint.activate([ - statusView.lockImageView.heightAnchor.constraint(equalTo: statusView.authorUsernameLabel.heightAnchor).priority(.required - 10), - ]) - statusView.lockImageView.setContentHuggingPriority(.defaultLow, for: .horizontal) - statusView.lockImageView.setContentCompressionResistancePriority(.required - 10, for: .horizontal) - statusView.authorUsernameLabel.setContentCompressionResistancePriority(.required - 11, for: .horizontal) - // padding - authorContentStackView.addArrangedSubview(UIView()) - - NSLayoutConstraint.activate([ - statusView.authorAvatarButton.heightAnchor.constraint(equalTo: statusView.authorNameLabel.heightAnchor, multiplier: 1.0).priority(.required - 10), - statusView.authorAvatarButton.widthAnchor.constraint(equalTo: statusView.authorNameLabel.heightAnchor, multiplier: 1.0).priority(.required - 10), - ]) - // low priority for intrinsic size hugging - statusView.authorAvatarButton.setContentHuggingPriority(.defaultLow - 10, for: .vertical) - statusView.authorAvatarButton.setContentHuggingPriority(.defaultLow - 10, for: .horizontal) - // high priority but lower then layout constraint for size compression - statusView.authorAvatarButton.setContentCompressionResistancePriority(.required - 11, for: .vertical) - statusView.authorAvatarButton.setContentCompressionResistancePriority(.required - 11, for: .horizontal) - statusView.authorNameLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical) - statusView.authorNameLabel.setContentHuggingPriority(.required - 1, for: .vertical) - - // contentTextView - statusView.contentTextView.translatesAutoresizingMaskIntoConstraints = false - bodyContainerStackView.addArrangedSubview(statusView.contentTextView) - statusView.contentTextView.setContentHuggingPriority(.required - 10, for: .vertical) - statusView.contentTextView.textAttributes = [ - .font: UIFont.preferredFont(forTextStyle: .callout), - .foregroundColor: UIColor.secondaryLabel, - ] - statusView.contentTextView.linkAttributes = [ - .font: UIFont.preferredFont(forTextStyle: .callout), - .foregroundColor: Asset.Colors.Theme.daylight.color - ] - - // mediaGridContainerView - statusView.mediaGridContainerView.translatesAutoresizingMaskIntoConstraints = false - bodyContainerStackView.addArrangedSubview(statusView.mediaGridContainerView) - } - - func layoutComposeReply(statusView: StatusView) { - // container: V - [ body container ] - - // body container: H - [ authorAvatarButton | content container ] - let bodyContainerStackView = UIStackView() - bodyContainerStackView.axis = .horizontal - bodyContainerStackView.spacing = StatusView.bodyContainerStackViewSpacing - bodyContainerStackView.alignment = .top - statusView.containerStackView.addArrangedSubview(bodyContainerStackView) - - // authorAvatarButton - let authorAvatarButtonSize = CGSize(width: 44, height: 44) - statusView.authorAvatarButton.size = authorAvatarButtonSize - statusView.authorAvatarButton.avatarImageView.imageViewSize = authorAvatarButtonSize - statusView.authorAvatarButton.translatesAutoresizingMaskIntoConstraints = false - bodyContainerStackView.addArrangedSubview(statusView.authorAvatarButton) - NSLayoutConstraint.activate([ - statusView.authorAvatarButton.widthAnchor.constraint(equalToConstant: authorAvatarButtonSize.width).priority(.required - 1), - statusView.authorAvatarButton.heightAnchor.constraint(equalToConstant: authorAvatarButtonSize.height).priority(.required - 1), - ]) - - // content container: V - [ author content | contentTextView | mediaGridContainerView | quoteStatusView | … | location content | toolbar ] - let contentContainerView = UIStackView() - contentContainerView.axis = .vertical - contentContainerView.spacing = 10 - bodyContainerStackView.addArrangedSubview(contentContainerView) - - // author content: H - [ authorNameLabel | lockImageView | authorUsernameLabel | padding | visibilityImageView (for Mastodon) | timestampLabel ] - let authorContentStackView = UIStackView() - authorContentStackView.axis = .horizontal - authorContentStackView.spacing = 6 - contentContainerView.addArrangedSubview(authorContentStackView) - contentContainerView.setCustomSpacing(4, after: authorContentStackView) - UIContentSizeCategory.publisher - .sink { category in - authorContentStackView.axis = category > .accessibilityLarge ? .vertical : .horizontal - } - .store(in: &statusView._disposeBag) - - // authorNameLabel - authorContentStackView.addArrangedSubview(statusView.authorNameLabel) - statusView.authorNameLabel.setContentCompressionResistancePriority(.required - 10, for: .horizontal) - // lockImageView - statusView.lockImageView.translatesAutoresizingMaskIntoConstraints = false - authorContentStackView.addArrangedSubview(statusView.lockImageView) - // authorUsernameLabel - authorContentStackView.addArrangedSubview(statusView.authorUsernameLabel) - NSLayoutConstraint.activate([ - statusView.lockImageView.heightAnchor.constraint(equalTo: statusView.authorUsernameLabel.heightAnchor).priority(.required - 10), - ]) - statusView.lockImageView.setContentHuggingPriority(.defaultLow, for: .horizontal) - statusView.lockImageView.setContentCompressionResistancePriority(.required - 10, for: .horizontal) - statusView.authorUsernameLabel.setContentCompressionResistancePriority(.required - 11, for: .horizontal) - // padding - authorContentStackView.addArrangedSubview(UIView()) - // visibilityImageView - authorContentStackView.addArrangedSubview(statusView.visibilityImageView) - statusView.visibilityImageView.setContentHuggingPriority(.required - 9, for: .horizontal) - statusView.visibilityImageView.setContentCompressionResistancePriority(.required - 9, for: .horizontal) - // timestampLabel - authorContentStackView.addArrangedSubview(statusView.timestampLabel) - statusView.timestampLabel.setContentHuggingPriority(.required - 9, for: .horizontal) - statusView.timestampLabel.setContentCompressionResistancePriority(.required - 9, for: .horizontal) - - // spoilerContentTextView - contentContainerView.addArrangedSubview(statusView.spoilerContentTextView) - statusView.spoilerContentTextView.setContentCompressionResistancePriority(.required - 10, for: .vertical) - statusView.spoilerContentTextView.setContentHuggingPriority(.required - 9, for: .vertical) - - contentContainerView.addArrangedSubview(statusView.expandContentButtonContainer) - statusView.expandContentButton.translatesAutoresizingMaskIntoConstraints = false - statusView.expandContentButtonContainer.addArrangedSubview(statusView.expandContentButton) - statusView.expandContentButtonContainer.addArrangedSubview(UIView()) - - // contentTextView - statusView.contentTextView.translatesAutoresizingMaskIntoConstraints = false - contentContainerView.addArrangedSubview(statusView.contentTextView) - statusView.contentTextView.setContentHuggingPriority(.required - 10, for: .vertical) - - // mediaGridContainerView - statusView.mediaGridContainerView.translatesAutoresizingMaskIntoConstraints = false - contentContainerView.addArrangedSubview(statusView.mediaGridContainerView) - - // quoteStatusView - let quoteStatusView = StatusView() - quoteStatusView.setup(style: .quote) - statusView.quoteStatusView = quoteStatusView - contentContainerView.addArrangedSubview(quoteStatusView) - - // location content: H - [ locationMapPinImageView | locationLabel ] - contentContainerView.addArrangedSubview(statusView.locationContainer) - - statusView.locationMapPinImageView.translatesAutoresizingMaskIntoConstraints = false - statusView.locationLabel.translatesAutoresizingMaskIntoConstraints = false - - // locationMapPinImageView - statusView.locationContainer.addArrangedSubview(statusView.locationMapPinImageView) - // locationLabel - statusView.locationContainer.addArrangedSubview(statusView.locationLabel) - - NSLayoutConstraint.activate([ - statusView.locationMapPinImageView.heightAnchor.constraint(equalTo: statusView.locationLabel.heightAnchor, multiplier: 1.0).priority(.required - 1), - statusView.locationMapPinImageView.widthAnchor.constraint(equalTo: statusView.locationMapPinImageView.heightAnchor, multiplier: 1.0).priority(.required - 1), - ]) - statusView.locationLabel.setContentHuggingPriority(.required - 10, for: .vertical) - statusView.locationMapPinImageView.setContentHuggingPriority(.defaultLow, for: .vertical) - statusView.locationMapPinImageView.setContentHuggingPriority(.defaultLow, for: .horizontal) - } - -} - -extension StatusView { - - private func update(theme: Theme) { - headerIconImageView.tintColor = theme.accentColor - } - - public func setHeaderDisplay() { - headerContainerView.isHidden = false - } - - public func setLockDisplay() { - lockImageView.isHidden = false - } - - public func setVisibilityDisplay() { - visibilityImageView.isHidden = false - } - - public func setSpoilerDisplay() { - spoilerContentTextView.isHidden = false - expandContentButtonContainer.isHidden = false - } - - public func setTranslateButtonDisplay() { - translateButtonContainer.isHidden = false - } - - public func setMediaDisplay() { - mediaGridContainerView.isHidden = false - } - - public func setPollDisplay() { - pollTableView.isHidden = false - pollVoteInfoContainerView.isHidden = false - } - - public func setQuoteDisplay() { - quoteStatusView?.isHidden = false + } // end HStack } - public func setLocationDisplay() { - locationContainer.isHidden = false - } - - public func setReplySettingsDisplay() { - replySettingBannerView.isHidden = false - } - - // content text Width - public var contentMaxLayoutWidth: CGFloat { - let inset = contentLayoutInset - return frame.width - inset.left - inset.right + var avatarButtonClipShape: any Shape { + switch viewModel.avatarStyle { + case .circle: + return Circle() + case .roundedSquare: + return RoundedRectangle(cornerRadius: avatarButtonDimension / 4) + } } - public var contentLayoutInset: UIEdgeInsets { - guard let style = style else { - assertionFailure("Needs setup style before use") - return .zero - } - - switch style { - case .inline, .composeReply: - let left = authorAvatarButton.size.width + StatusView.bodyContainerStackViewSpacing - return UIEdgeInsets(top: 0, left: left, bottom: 0, right: 0) - case .plain: - return .zero + var avatarButtonDimension: CGFloat { + switch viewModel.kind { case .quote: - let margin = StatusView.quoteStatusViewContainerLayoutMargin - return UIEdgeInsets(top: 0, left: margin, bottom: 0, right: margin) + return inlineAvatarButtonDimension + default: + return StatusView.hangingAvatarButtonDimension } } - -} - -extension StatusView { - @objc private func headerTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") - delegate?.statusView(self, headerDidPressed: headerContainerView) - } - - @objc private func authorAvatarButtonDidPressed(_ sender: UIButton) { - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") - delegate?.statusView(self, authorAvatarButtonDidPressed: authorAvatarButton) - } - - @objc private func expandContentButtonDidPressed(_ sender: UIButton) { - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") - delegate?.statusView(self, expandContentButtonDidPressed: sender) - } - - @objc private func pollVoteButtonDidPressed(_ sender: UIButton) { - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") - delegate?.statusView(self, pollVoteButtonDidPressed: sender) - } - - @objc private func quoteStatusViewDidPressed(_ sender: UITapGestureRecognizer) { - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") - guard let quoteStatusView = quoteStatusView else { return } - delegate?.statusView(self, quoteStatusViewDidPressed: quoteStatusView) - } - @objc private func quoteAuthorAvatarButtonDidPressed(_ sender: UITapGestureRecognizer) { - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") - guard let quoteStatusView = quoteStatusView else { return } - delegate?.statusView(self, quoteStatusView: quoteStatusView, authorAvatarButtonDidPressed: quoteStatusView.authorAvatarButton) - } - - @objc private func translateButtonDidPressed(_ sender: UIButton) { - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") - delegate?.statusView(self, translateButtonDidPressed: sender) - } -} - -// MARK: - MetaTextAreaViewDelegate -extension StatusView: MetaTextAreaViewDelegate { - public func metaTextAreaView(_ metaTextAreaView: MetaTextAreaView, didSelectMeta meta: Meta) { - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public))") - delegate?.statusView(self, metaTextAreaView: metaTextAreaView, didSelectMeta: meta) - } -} - -// MARK: - MediaGridContainerViewDelegate -extension StatusView: MediaGridContainerViewDelegate { - public func mediaGridContainerView(_ container: MediaGridContainerView, didTapMediaView mediaView: MediaView, at index: Int) { - delegate?.statusView(self, mediaGridContainerView: container, didTapMediaView: mediaView, at: index) - } - - public func mediaGridContainerView(_ container: MediaGridContainerView, toggleContentWarningOverlayViewDisplay contentWarningOverlayView: ContentWarningOverlayView) { - delegate?.statusView(self, mediaGridContainerView: container, toggleContentWarningOverlayViewDisplay: contentWarningOverlayView) - } -} - -// MARK: - StatusToolbarDelegate -extension StatusView: StatusToolbarDelegate { - public func statusToolbar(_ statusToolbar: StatusToolbar, actionDidPressed action: StatusToolbar.Action, button: UIButton) { - delegate?.statusView(self, statusToolbar: statusToolbar, actionDidPressed: action, button: button) - } - - public func statusToolbar(_ statusToolbar: StatusToolbar, menuActionDidPressed action: StatusToolbar.MenuAction, menuButton button: UIButton) { - delegate?.statusView(self, statusToolbar: statusToolbar, menuActionDidPressed: action, menuButton: button) - } -} - -// MARK: - StatusViewDelegate -// relay for quoteStatsView -extension StatusView: StatusViewDelegate { - - public func statusView(_ statusView: StatusView, headerDidPressed header: UIView) { - assertionFailure() - } - - public func statusView(_ statusView: StatusView, authorAvatarButtonDidPressed button: AvatarButton) { - guard statusView === quoteStatusView else { - assertionFailure() - return + var avatarButton: some View { + Button { + guard let author = viewModel.author?.asRecord else { return } + viewModel.delegate?.statusView(viewModel, userAvatarButtonDidPressed: author) + } label: { + KFImage(viewModel.avatarURL) + .placeholder { progress in + Color(uiColor: .placeholderText) + } + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: avatarButtonDimension, height: avatarButtonDimension) + .clipShape(AvatarClipShape(avatarStyle: viewModel.avatarStyle)) + .animation(.easeInOut, value: viewModel.avatarStyle) } - - delegate?.statusView(self, quoteStatusView: statusView, authorAvatarButtonDidPressed: button) + .buttonStyle(.borderless) } - public func statusView(_ statusView: StatusView, quoteStatusView: StatusView, authorAvatarButtonDidPressed button: AvatarButton) { - assertionFailure() - } - - public func statusView(_ statusView: StatusView, metaTextAreaView: MetaTextAreaView, didSelectMeta meta: Meta) { - guard statusView === quoteStatusView else { - assertionFailure() - return + var spoilerContentView: some View { + VStack(alignment: .leading, spacing: .zero) { + TextViewRepresentable( + metaContent: viewModel.spoilerContent ?? PlaintextMetaContent(string: ""), + textStyle: .statusContent, + width: viewModel.contentWidth, + isSelectable: viewModel.kind == .conversationRoot, + handler: { meta in + viewModel.delegate?.statusView(viewModel, textViewDidSelectMeta: meta) + } + ) + .frame(width: viewModel.contentWidth) + .onTapGesture { + // ignore tap + } } - - delegate?.statusView(self, quoteStatusView: statusView, metaTextAreaView: metaTextAreaView, didSelectMeta: meta) - } - - public func statusView(_ statusView: StatusView, expandContentButtonDidPressed button: UIButton) { - assertionFailure() } - public func statusView(_ statusView: StatusView, quoteStatusView: StatusView, metaTextAreaView: MetaTextAreaView, didSelectMeta meta: Meta) { - assertionFailure() - } - - public func statusView(_ statusView: StatusView, mediaGridContainerView containerView: MediaGridContainerView, didTapMediaView mediaView: MediaView, at index: Int) { - guard statusView === quoteStatusView else { - assertionFailure() - return + var contentView: some View { + VStack(alignment: .leading, spacing: .zero) { + TextViewRepresentable( + metaContent: viewModel.content, + textStyle: .statusContent, + width: viewModel.contentWidth, + isSelectable: viewModel.kind == .conversationRoot, + handler: { meta in + viewModel.delegate?.statusView(viewModel, textViewDidSelectMeta: meta) + } + ) + .frame(width: viewModel.contentWidth) + .onTapGesture { + // ignore tap + } } - - delegate?.statusView(self, quoteStatusView: statusView, mediaGridContainerView: containerView, didTapMediaView: mediaView, at: index) - } - - public func statusView(_ statusView: StatusView, pollTableView tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - assertionFailure() - } - - public func statusView(_ statusView: StatusView, pollVoteButtonDidPressed button: UIButton) { - assertionFailure() - } - - public func statusView(_ statusView: StatusView, quoteStatusView: StatusView, mediaGridContainerView containerView: MediaGridContainerView, didTapMediaView mediaView: MediaView, at index: Int) { - assertionFailure() - } - - public func statusView(_ statusView: StatusView, mediaGridContainerView containerView: MediaGridContainerView, toggleContentWarningOverlayViewDisplay contentWarningOverlayView: ContentWarningOverlayView) { - assertionFailure() - } - - public func statusView(_ statusView: StatusView, quoteStatusViewDidPressed quoteStatusView: StatusView) { - assertionFailure() - } - - public func statusView(_ statusView: StatusView, statusToolbar: StatusToolbar, actionDidPressed action: StatusToolbar.Action, button: UIButton) { - assertionFailure() } - public func statusView(_ statusView: StatusView, statusToolbar: StatusToolbar, menuActionDidPressed action: StatusToolbar.MenuAction, menuButton button: UIButton) { - assertionFailure() - } - - public func statusView(_ statusView: StatusView, translateButtonDidPressed button: UIButton) { - assertionFailure() - } - - public func statusView(_ statusView: StatusView, accessibilityActivate: Void) { - assertionFailure() + 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) + } } -} - -// MARK: - UITableViewDelegate -extension StatusView: UITableViewDelegate { - public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): select \(indexPath.debugDescription)") - - switch tableView { - case pollTableView: - delegate?.statusView(self, pollTableView: tableView, didSelectRowAt: indexPath) - default: - assertionFailure() - } + var toolbarView: some View { + StatusToolbarView( + viewModel: viewModel.toolbarViewModel, + menuActions: { + var actions: [StatusToolbarView.Action] = [] + // copyText + actions.append(.copyText) + // copyLink + actions.append(.copyLink) + // shareLink + actions.append(.shareLink) + // save media + if !viewModel.mediaViewModels.isEmpty { + actions.append(.saveMedia) + } + // translate + actions.append(.translate) + if viewModel.canDelete { + actions.append(.delete) + } + return actions + }(), + handler: { action in + viewModel.delegate?.statusView( + viewModel, + statusToolbarViewModel: viewModel.toolbarViewModel, + statusToolbarButtonDidPressed: action + ) + } + ) + .frame(height: 48) } } -// MARK: - UIGestureRecognizerDelegate -extension StatusView: UIGestureRecognizerDelegate { - public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { - if let view = touch.view, view is AvatarButton { - return false - } - - return true - } -} +//public final class StatusView: UIView { +// +// private var _disposeBag = Set() // which lifetime same to view scope +// public var disposeBag = Set() // clear when reuse +// +// public weak var delegate: StatusViewDelegate? +// +// public static let bodyContainerStackViewSpacing: CGFloat = 10 +// public static let quoteStatusViewContainerLayoutMargin: CGFloat = 12 +// +// let logger = Logger(subsystem: "StatusView", category: "View") +// +// public private(set) var style: Style? +// +// public private(set) lazy var viewModel: ViewModel = { +// let viewModel = ViewModel() +// viewModel.bind(statusView: self) +// return viewModel +// }() +// +// // container +// public let containerStackView: UIStackView = { +// let stackView = UIStackView() +// stackView.axis = .vertical +// stackView.spacing = 8 +// return stackView +// }() +// +// // header +// public let headerContainerView = UIView() +// public let headerIconImageView = UIImageView() +// public static var headerTextLabelStyle: TextStyle { .statusHeader } +// public let headerTextLabel: MetaLabel = { +// let label = MetaLabel(style: .statusHeader) +// label.isUserInteractionEnabled = false +// return label +// }() +// +// // avatar +// public let authorAvatarButton = AvatarButton() +// +// // author +// public static var authorNameLabelStyle: TextStyle { .statusAuthorName } +// public let authorNameLabel: MetaLabel = { +// let label = MetaLabel(style: StatusView.authorNameLabelStyle) +// label.accessibilityTraits = .staticText +// return label +// }() +// public let lockImageView: UIImageView = { +// let imageView = UIImageView() +// imageView.tintColor = .secondaryLabel +// imageView.contentMode = .scaleAspectFill +// imageView.image = Asset.ObjectTools.lockMiniInline.image.withRenderingMode(.alwaysTemplate) +// return imageView +// }() +// public let authorUsernameLabel = PlainLabel(style: .statusAuthorUsername) +// public let visibilityImageView: UIImageView = { +// let imageView = UIImageView() +// imageView.tintColor = .secondaryLabel +// imageView.contentMode = .scaleAspectFill +// imageView.image = Asset.ObjectTools.globeMiniInline.image.withRenderingMode(.alwaysTemplate) +// return imageView +// }() +// public let timestampLabel = PlainLabel(style: .statusTimestamp) +// +// // spoiler +// public let spoilerContentTextView: MetaTextAreaView = { +// let textView = MetaTextAreaView() +// textView.textAttributes = [ +// .font: UIFont.preferredFont(forTextStyle: .body), +// .foregroundColor: UIColor.label.withAlphaComponent(0.8), +// ] +// textView.linkAttributes = [ +// .font: UIFont.preferredFont(forTextStyle: .body), +// .foregroundColor: Asset.Colors.Theme.daylight.color +// ] +// return textView +// }() +// +// public let expandContentButtonContainer: UIStackView = { +// let stackView = UIStackView() +// stackView.axis = .horizontal +// stackView.alignment = .leading +// return stackView +// }() +// public let expandContentButton: HitTestExpandedButton = { +// let button = HitTestExpandedButton(type: .system) +// button.setImage(Asset.Editing.ellipsisLarge.image.withRenderingMode(.alwaysTemplate), for: .normal) +// button.layer.masksToBounds = true +// button.layer.cornerRadius = 10 +// button.layer.cornerCurve = .circular +// button.backgroundColor = .tertiarySystemFill +// return button +// }() +// +// // content +// public let contentTextView: MetaTextAreaView = { +// let textView = MetaTextAreaView() +// textView.textAttributes = [ +// .font: UIFont.preferredFont(forTextStyle: .body), +// .foregroundColor: UIColor.label.withAlphaComponent(0.8), +// ] +// textView.linkAttributes = [ +// .font: UIFont.preferredFont(forTextStyle: .body), +// .foregroundColor: Asset.Colors.Theme.daylight.color +// ] +// return textView +// }() +// +// // translate +// let translateButtonContainer = UIStackView() +// public let translateButton: UIButton = { +// let button = HitTestExpandedButton(type: .system) +// button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 14, weight: .medium)) +// button.setTitle(L10n.Common.Controls.Status.Actions.translate, for: .normal) +// return button +// }() +// +// // media +// public let mediaGridContainerView = MediaGridContainerView() +// +// // poll +// public let pollTableView: UITableView = { +// let tableView = UITableView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) +// tableView.register(PollOptionTableViewCell.self, forCellReuseIdentifier: String(describing: PollOptionTableViewCell.self)) +// tableView.isScrollEnabled = false +// tableView.estimatedRowHeight = 36 +// tableView.tableFooterView = UIView() +// tableView.backgroundColor = .clear +// tableView.separatorStyle = .none +// return tableView +// }() +// public var pollTableViewHeightLayoutConstraint: NSLayoutConstraint! +// public var pollTableViewDiffableDataSource: UITableViewDiffableDataSource? +// +// public let pollVoteInfoContainerView = UIStackView() +// public let pollVoteDescriptionLabel = PlainLabel(style: .pollVoteDescription) +// public let pollVoteButton: HitTestExpandedButton = { +// let button = HitTestExpandedButton(type: .system) +// button.setTitle(L10n.Common.Controls.Status.Actions.vote, for: .normal) +// button.setTitleColor(Asset.Colors.hightLight.color, for: .normal) +// button.setTitleColor(.secondaryLabel, for: .disabled) +// return button +// }() +// public let pollVoteActivityIndicatorView: UIActivityIndicatorView = { +// let activityIndicatorView = UIActivityIndicatorView(style: .medium) +// activityIndicatorView.hidesWhenStopped = true +// activityIndicatorView.tintColor = .secondaryLabel +// return activityIndicatorView +// }() +// +// // quote +// public private(set) var quoteStatusView: StatusView? { +// didSet { +// if let quoteStatusView = quoteStatusView { +// quoteStatusView.delegate = self +// +// let quoteTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer +// quoteTapGestureRecognizer.delegate = self +// quoteTapGestureRecognizer.addTarget(self, action: #selector(StatusView.quoteStatusViewDidPressed(_:))) +// quoteStatusView.addGestureRecognizer(quoteTapGestureRecognizer) +// } +// } +// } +// +// // location +// public let locationContainer: UIStackView = { +// let stackView = UIStackView() +// stackView.axis = .horizontal +// stackView.spacing = 6 +// return stackView +// }() +// public let locationMapPinImageView: UIImageView = { +// let imageView = UIImageView() +// imageView.tintColor = .secondaryLabel +// return imageView +// }() +// public let locationLabel = PlainLabel(style: .statusLocation) +// +// // metrics +// public let metricsDashboardView = StatusMetricsDashboardView() +// +// // toolbar +// public let toolbar = StatusToolbar() +// +// // reply settings +// public let replySettingBannerView = ReplySettingBannerView() +// +// public override init(frame: CGRect) { +// super.init(frame: frame) +// _init() +// } +// +// public required init?(coder: NSCoder) { +// super.init(coder: coder) +// _init() +// } +// +// public override func willMove(toWindow newWindow: UIWindow?) { +// super.willMove(toWindow: newWindow) +// assert(style != nil, "Needs setup style before use") +// } +// +// deinit { +// viewModel.disposeBag.removeAll() +// } +// +//} +// +//extension StatusView { +// +// public func prepareForReuse() { +// disposeBag.removeAll() +// viewModel.objects.removeAll() +// viewModel.authorAvatarImageURL = nil +// authorAvatarButton.avatarImageView.cancelTask() +// quoteStatusView?.prepareForReuse() +// mediaGridContainerView.prepareForReuse() +// if var snapshot = pollTableViewDiffableDataSource?.snapshot() { +// snapshot.deleteAllItems() +// pollTableViewDiffableDataSource?.applySnapshotUsingReloadData(snapshot) +// } +// Style.prepareForReuse(statusView: self) +// } +// +// private func _init() { +// containerStackView.translatesAutoresizingMaskIntoConstraints = false +// addSubview(containerStackView) +// NSLayoutConstraint.activate([ +// containerStackView.topAnchor.constraint(equalTo: topAnchor), +// containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor), +// containerStackView.trailingAnchor.constraint(equalTo: trailingAnchor), +// containerStackView.bottomAnchor.constraint(equalTo: bottomAnchor), +// ]) +// +// // header +// let headerTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer +// headerTapGestureRecognizer.addTarget(self, action: #selector(StatusView.headerTapGestureRecognizerHandler(_:))) +// headerContainerView.addGestureRecognizer(headerTapGestureRecognizer) +// +// // avatar button +// authorAvatarButton.accessibilityLabel = L10n.Accessibility.Common.Status.authorAvatar +// authorAvatarButton.accessibilityHint = L10n.Accessibility.VoiceOver.doubleTapToOpenProfile +// authorAvatarButton.addTarget(self, action: #selector(StatusView.authorAvatarButtonDidPressed(_:)), for: .touchUpInside) +// +// // expand content +// expandContentButton.addTarget(self, action: #selector(StatusView.expandContentButtonDidPressed(_:)), for: .touchUpInside) +// +// // content +// contentTextView.delegate = self +// +// // translateButton +// translateButton.addTarget(self, action: #selector(StatusView.translateButtonDidPressed(_:)), for: .touchUpInside) +// +// // media grid +// mediaGridContainerView.delegate = self +// +// // poll +// pollTableView.translatesAutoresizingMaskIntoConstraints = false +// pollTableViewHeightLayoutConstraint = pollTableView.heightAnchor.constraint(equalToConstant: 36).priority(.required - 10) +// NSLayoutConstraint.activate([ +// pollTableViewHeightLayoutConstraint, +// ]) +// pollTableView.delegate = self +// pollVoteButton.addTarget(self, action: #selector(StatusView.pollVoteButtonDidPressed(_:)), for: .touchUpInside) +// +// // toolbar +// toolbar.delegate = self +// +// // theme +// ThemeService.shared.theme +// .sink { [weak self] theme in +// guard let self = self else { return } +// self.update(theme: theme) +// } +// .store(in: &disposeBag) +// } +// +// public func setup(style: Style) { +// guard self.style == nil else { +// assertionFailure("Should only setup once") +// return +// } +// self.style = style +// style.layout(statusView: self) +// Style.prepareForReuse(statusView: self) +// } +// +//} +// +//extension StatusView { +// public enum Style { +// case inline // for timeline +// case plain // for thread +// case quote // for quote +// case composeReply // for compose +// +// func layout(statusView: StatusView) { +// switch self { +// case .inline: layoutInline(statusView: statusView) +// case .plain: layoutPlain(statusView: statusView) +// case .quote: layoutQuote(statusView: statusView) +// case .composeReply: layoutComposeReply(statusView: statusView) +// } +// } +// +// static func prepareForReuse(statusView: StatusView) { +// statusView.headerContainerView.isHidden = true +// statusView.lockImageView.isHidden = true +// statusView.visibilityImageView.isHidden = true +// statusView.spoilerContentTextView.isHidden = true +// statusView.expandContentButtonContainer.isHidden = true +// statusView.translateButtonContainer.isHidden = true +// statusView.mediaGridContainerView.isHidden = true +// statusView.pollTableView.isHidden = true +// statusView.pollVoteInfoContainerView.isHidden = true +// statusView.pollVoteActivityIndicatorView.isHidden = true +// statusView.quoteStatusView?.isHidden = true +// statusView.locationContainer.isHidden = true +// statusView.replySettingBannerView.isHidden = true +// } +// } +//} +// +//extension StatusView.Style { +// +// private func layoutInline(statusView: StatusView) { +// // container: V - [ header container | body container ] +// +// // header container: H - [ icon | label ] +// statusView.containerStackView.addArrangedSubview(statusView.headerContainerView) +// statusView.headerIconImageView.translatesAutoresizingMaskIntoConstraints = false +// statusView.headerTextLabel.translatesAutoresizingMaskIntoConstraints = false +// statusView.headerContainerView.addSubview(statusView.headerIconImageView) +// statusView.headerContainerView.addSubview(statusView.headerTextLabel) +// NSLayoutConstraint.activate([ +// statusView.headerTextLabel.topAnchor.constraint(equalTo: statusView.headerContainerView.topAnchor), +// statusView.headerTextLabel.bottomAnchor.constraint(equalTo: statusView.headerContainerView.bottomAnchor), +// statusView.headerTextLabel.trailingAnchor.constraint(equalTo: statusView.headerContainerView.trailingAnchor), +// statusView.headerIconImageView.centerYAnchor.constraint(equalTo: statusView.headerTextLabel.centerYAnchor), +// statusView.headerIconImageView.heightAnchor.constraint(equalTo: statusView.headerTextLabel.heightAnchor, multiplier: 1.0).priority(.required - 1), +// statusView.headerIconImageView.widthAnchor.constraint(equalTo: statusView.headerIconImageView.heightAnchor, multiplier: 1.0).priority(.required - 1), +// statusView.headerTextLabel.leadingAnchor.constraint(equalTo: statusView.headerIconImageView.trailingAnchor, constant: 4), +// // align to author name below +// ]) +// statusView.headerTextLabel.setContentHuggingPriority(.required - 10, for: .vertical) +// statusView.headerIconImageView.setContentHuggingPriority(.defaultLow, for: .vertical) +// statusView.headerIconImageView.setContentHuggingPriority(.defaultLow, for: .horizontal) +// +// // body container: H - [ authorAvatarButton | content container ] +// let bodyContainerStackView = UIStackView() +// bodyContainerStackView.axis = .horizontal +// bodyContainerStackView.spacing = StatusView.bodyContainerStackViewSpacing +// bodyContainerStackView.alignment = .top +// statusView.containerStackView.addArrangedSubview(bodyContainerStackView) +// +// // authorAvatarButton +// let authorAvatarButtonSize = CGSize(width: 44, height: 44) +// statusView.authorAvatarButton.size = authorAvatarButtonSize +// statusView.authorAvatarButton.avatarImageView.imageViewSize = authorAvatarButtonSize +// statusView.authorAvatarButton.translatesAutoresizingMaskIntoConstraints = false +// bodyContainerStackView.addArrangedSubview(statusView.authorAvatarButton) +// NSLayoutConstraint.activate([ +// statusView.authorAvatarButton.widthAnchor.constraint(equalToConstant: authorAvatarButtonSize.width).priority(.required - 1), +// statusView.authorAvatarButton.heightAnchor.constraint(equalToConstant: authorAvatarButtonSize.height).priority(.required - 1), +// ]) +// +// // content container: V - [ author content | contentTextView | mediaGridContainerView | quoteStatusView | … | location content | toolbar ] +// let contentContainerView = UIStackView() +// contentContainerView.axis = .vertical +// contentContainerView.spacing = 10 +// bodyContainerStackView.addArrangedSubview(contentContainerView) +// +// // author content: H - [ authorNameLabel | lockImageView | authorUsernameLabel | padding | visibilityImageView (for Mastodon) | timestampLabel ] +// let authorContentStackView = UIStackView() +// authorContentStackView.axis = .horizontal +// authorContentStackView.spacing = 6 +// contentContainerView.addArrangedSubview(authorContentStackView) +// contentContainerView.setCustomSpacing(4, after: authorContentStackView) +// UIContentSizeCategory.publisher +// .sink { category in +// authorContentStackView.axis = category > .accessibilityLarge ? .vertical : .horizontal +// authorContentStackView.alignment = category > .accessibilityLarge ? .leading : .fill +// } +// .store(in: &statusView._disposeBag) +// +// // authorNameLabel +// authorContentStackView.addArrangedSubview(statusView.authorNameLabel) +// statusView.authorNameLabel.setContentHuggingPriority(.required - 10, for: .horizontal) +// statusView.authorNameLabel.setContentCompressionResistancePriority(.required - 10, for: .horizontal) +// // lockImageView +// statusView.lockImageView.translatesAutoresizingMaskIntoConstraints = false +// authorContentStackView.addArrangedSubview(statusView.lockImageView) +// // authorUsernameLabel +// authorContentStackView.addArrangedSubview(statusView.authorUsernameLabel) +// NSLayoutConstraint.activate([ +// statusView.lockImageView.heightAnchor.constraint(equalTo: statusView.authorUsernameLabel.heightAnchor).priority(.required - 10), +// ]) +// statusView.lockImageView.setContentHuggingPriority(.defaultLow, for: .horizontal) +// statusView.lockImageView.setContentCompressionResistancePriority(.required - 10, for: .horizontal) +// statusView.authorUsernameLabel.setContentCompressionResistancePriority(.required - 11, for: .horizontal) +// // padding +// authorContentStackView.addArrangedSubview(UIView()) +// // visibilityImageView +// authorContentStackView.addArrangedSubview(statusView.visibilityImageView) +// statusView.visibilityImageView.setContentHuggingPriority(.required - 9, for: .horizontal) +// statusView.visibilityImageView.setContentCompressionResistancePriority(.required - 9, for: .horizontal) +// // timestampLabel +// authorContentStackView.addArrangedSubview(statusView.timestampLabel) +// statusView.timestampLabel.setContentHuggingPriority(.required - 8, for: .horizontal) +// statusView.timestampLabel.setContentCompressionResistancePriority(.required - 8, for: .horizontal) +// +// // set header label align to author name +// NSLayoutConstraint.activate([ +// statusView.headerTextLabel.leadingAnchor.constraint(equalTo: statusView.authorNameLabel.leadingAnchor), +// ]) +// +// // spoilerContentTextView +// contentContainerView.addArrangedSubview(statusView.spoilerContentTextView) +// statusView.spoilerContentTextView.setContentHuggingPriority(.required - 9, for: .vertical) +// statusView.spoilerContentTextView.setContentCompressionResistancePriority(.required - 10, for: .vertical) +// +// contentContainerView.addArrangedSubview(statusView.expandContentButtonContainer) +// statusView.expandContentButton.translatesAutoresizingMaskIntoConstraints = false +// statusView.expandContentButtonContainer.addArrangedSubview(statusView.expandContentButton) +// statusView.expandContentButtonContainer.addArrangedSubview(UIView()) +// +// // contentTextView +// // statusView.contentTextView.translatesAutoresizingMaskIntoConstraints = false +// contentContainerView.addArrangedSubview(statusView.contentTextView) +// statusView.contentTextView.setContentHuggingPriority(.required - 10, for: .vertical) +// +// // mediaGridContainerView +// statusView.mediaGridContainerView.translatesAutoresizingMaskIntoConstraints = false +// contentContainerView.addArrangedSubview(statusView.mediaGridContainerView) +// +// // pollTableView +// contentContainerView.addArrangedSubview(statusView.pollTableView) +// +// statusView.pollVoteInfoContainerView.axis = .horizontal +// statusView.pollVoteInfoContainerView.addArrangedSubview(statusView.pollVoteDescriptionLabel) +// statusView.pollVoteInfoContainerView.addArrangedSubview(UIView()) // spacer +// statusView.pollVoteInfoContainerView.addArrangedSubview(statusView.pollVoteButton) +// statusView.pollVoteButton.setContentHuggingPriority(.required - 10, for: .horizontal) +// statusView.pollVoteButton.setContentCompressionResistancePriority(.required - 9, for: .horizontal) +// statusView.pollVoteInfoContainerView.addArrangedSubview(statusView.pollVoteActivityIndicatorView) +// statusView.pollVoteInfoContainerView.setContentHuggingPriority(.required - 11, for: .horizontal) +// statusView.pollVoteInfoContainerView.setContentCompressionResistancePriority(.required - 10, for: .horizontal) +// contentContainerView.addArrangedSubview(statusView.pollVoteInfoContainerView) +// +// // quoteStatusView +// let quoteStatusView = StatusView() +// quoteStatusView.setup(style: .quote) +// statusView.quoteStatusView = quoteStatusView +// contentContainerView.addArrangedSubview(quoteStatusView) +// +// // location content: H - [ locationMapPinImageView | locationLabel ] +// contentContainerView.addArrangedSubview(statusView.locationContainer) +// +// statusView.locationMapPinImageView.translatesAutoresizingMaskIntoConstraints = false +// statusView.locationLabel.translatesAutoresizingMaskIntoConstraints = false +// +// // locationMapPinImageView +// statusView.locationContainer.addArrangedSubview(statusView.locationMapPinImageView) +// // locationLabel +// statusView.locationContainer.addArrangedSubview(statusView.locationLabel) +// +// NSLayoutConstraint.activate([ +// statusView.locationMapPinImageView.heightAnchor.constraint(equalTo: statusView.locationLabel.heightAnchor, multiplier: 1.0).priority(.required - 1), +// statusView.locationMapPinImageView.widthAnchor.constraint(equalTo: statusView.locationMapPinImageView.heightAnchor, multiplier: 1.0).priority(.required - 1), +// ]) +// statusView.locationLabel.setContentHuggingPriority(.required - 10, for: .vertical) +// statusView.locationMapPinImageView.setContentHuggingPriority(.defaultLow, for: .vertical) +// statusView.locationMapPinImageView.setContentHuggingPriority(.defaultLow, for: .horizontal) +// +// // toolbar +// contentContainerView.addArrangedSubview(statusView.toolbar) +// statusView.toolbar.setContentHuggingPriority(.required - 9, for: .vertical) +// } +// +// private func layoutPlain(statusView: StatusView) { +// // container: V - [ header container | author container | contentTextView | mediaGridContainerView | quoteStatusView | location content | toolbar ] +// +// // header container: H - [ icon | label ] +// statusView.containerStackView.addArrangedSubview(statusView.headerContainerView) +// statusView.headerIconImageView.translatesAutoresizingMaskIntoConstraints = false +// statusView.headerTextLabel.translatesAutoresizingMaskIntoConstraints = false +// statusView.headerContainerView.addSubview(statusView.headerIconImageView) +// statusView.headerContainerView.addSubview(statusView.headerTextLabel) +// NSLayoutConstraint.activate([ +// statusView.headerTextLabel.topAnchor.constraint(equalTo: statusView.headerContainerView.topAnchor), +// statusView.headerTextLabel.bottomAnchor.constraint(equalTo: statusView.headerContainerView.bottomAnchor), +// statusView.headerTextLabel.trailingAnchor.constraint(equalTo: statusView.headerContainerView.trailingAnchor), +// statusView.headerIconImageView.centerYAnchor.constraint(equalTo: statusView.headerTextLabel.centerYAnchor), +// statusView.headerIconImageView.heightAnchor.constraint(equalTo: statusView.headerTextLabel.heightAnchor, multiplier: 1.0).priority(.required - 1), +// statusView.headerIconImageView.widthAnchor.constraint(equalTo: statusView.headerIconImageView.heightAnchor, multiplier: 1.0).priority(.required - 1), +// statusView.headerTextLabel.leadingAnchor.constraint(equalTo: statusView.headerIconImageView.trailingAnchor, constant: 4), +// // align to author name below +// ]) +// statusView.headerTextLabel.setContentHuggingPriority(.required - 10, for: .vertical) +// statusView.headerIconImageView.setContentHuggingPriority(.defaultLow, for: .vertical) +// statusView.headerIconImageView.setContentHuggingPriority(.defaultLow, for: .horizontal) +// statusView.headerContainerView.isHidden = true +// +// // author content: H - [ authorAvatarButton | author info content ] +// let authorContentStackView = UIStackView() +// let authorContentStackViewSpacing: CGFloat = 10 +// authorContentStackView.axis = .horizontal +// authorContentStackView.spacing = authorContentStackViewSpacing +// statusView.containerStackView.addArrangedSubview(authorContentStackView) +// +// // authorAvatarButton +// let authorAvatarButtonSize = CGSize(width: 44, height: 44) +// statusView.authorAvatarButton.size = authorAvatarButtonSize +// statusView.authorAvatarButton.avatarImageView.imageViewSize = authorAvatarButtonSize +// statusView.authorAvatarButton.translatesAutoresizingMaskIntoConstraints = false +// authorContentStackView.addArrangedSubview(statusView.authorAvatarButton) +// let authorAvatarButtonWidthFixLayoutConstraint = statusView.authorAvatarButton.widthAnchor.constraint(equalToConstant: authorAvatarButtonSize.width).priority(.required - 1) +// NSLayoutConstraint.activate([ +// authorAvatarButtonWidthFixLayoutConstraint, +// statusView.authorAvatarButton.heightAnchor.constraint(equalTo: statusView.authorAvatarButton.widthAnchor, multiplier: 1.0).priority(.required - 1), +// ]) +// +// // author info content: V - [ author info headline content | author info sub-headline content ] +// let authorInfoContentStackView = UIStackView() +// authorInfoContentStackView.axis = .vertical +// authorContentStackView.addArrangedSubview(authorInfoContentStackView) +// +// // author info headline content: H - [ authorNameLabel | lockImageView | padding | visibilityImageView (for Mastodon) ] +// let authorInfoHeadlineContentStackView = UIStackView() +// authorInfoHeadlineContentStackView.axis = .horizontal +// authorInfoHeadlineContentStackView.spacing = 2 +// authorInfoContentStackView.addArrangedSubview(authorInfoHeadlineContentStackView) +// +// // authorNameLabel +// authorInfoHeadlineContentStackView.addArrangedSubview(statusView.authorNameLabel) +// statusView.authorNameLabel.setContentCompressionResistancePriority(.required - 10, for: .horizontal) +// // lockImageView +// statusView.lockImageView.translatesAutoresizingMaskIntoConstraints = false +// authorInfoHeadlineContentStackView.addArrangedSubview(statusView.lockImageView) +// NSLayoutConstraint.activate([ +// statusView.lockImageView.heightAnchor.constraint(equalTo: statusView.authorNameLabel.heightAnchor).priority(.required - 10), +// ]) +// statusView.lockImageView.setContentHuggingPriority(.defaultLow, for: .horizontal) +// statusView.lockImageView.setContentCompressionResistancePriority(.required - 10, for: .horizontal) +// // padding +// authorInfoHeadlineContentStackView.addArrangedSubview(UIView()) +// // visibilityImageView +// authorInfoHeadlineContentStackView.addArrangedSubview(statusView.visibilityImageView) +// statusView.visibilityImageView.setContentHuggingPriority(.required - 9, for: .horizontal) +// statusView.visibilityImageView.setContentCompressionResistancePriority(.required - 9, for: .horizontal) +// +// // set header label align to author name +// NSLayoutConstraint.activate([ +// statusView.headerTextLabel.leadingAnchor.constraint(equalTo: statusView.authorAvatarButton.trailingAnchor, constant: authorContentStackViewSpacing), +// ]) +// +// // author info sub-headline content: H - [ authorUsernameLabel ] +// let authorInfoSubHeadlineContentStackView = UIStackView() +// authorInfoSubHeadlineContentStackView.axis = .horizontal +// authorInfoContentStackView.addArrangedSubview(authorInfoSubHeadlineContentStackView) +// +// UIContentSizeCategory.publisher +// .sink { category in +// if category >= .extraExtraLarge { +// authorContentStackView.axis = .vertical +// authorContentStackView.alignment = .leading // set leading +// } else { +// authorContentStackView.axis = .horizontal +// authorContentStackView.alignment = .fill // restore default +// } +// } +// .store(in: &statusView._disposeBag) +// +// // authorUsernameLabel +// authorInfoSubHeadlineContentStackView.addArrangedSubview(statusView.authorUsernameLabel) +// +// // spoilerContentTextView +// statusView.containerStackView.addArrangedSubview(statusView.spoilerContentTextView) +// statusView.spoilerContentTextView.setContentCompressionResistancePriority(.required - 10, for: .vertical) +// statusView.spoilerContentTextView.setContentHuggingPriority(.required - 9, for: .vertical) +// +// statusView.containerStackView.addArrangedSubview(statusView.expandContentButtonContainer) +// statusView.expandContentButton.translatesAutoresizingMaskIntoConstraints = false +// statusView.expandContentButtonContainer.addArrangedSubview(statusView.expandContentButton) +// statusView.expandContentButtonContainer.addArrangedSubview(UIView()) +// +// // contentTextView +// statusView.containerStackView.addArrangedSubview(statusView.contentTextView) +// +// // translateButtonContainer: H - [ translateButton | (spacer) ] +// statusView.translateButtonContainer.axis = .horizontal +// statusView.containerStackView.addArrangedSubview(statusView.translateButtonContainer) +// +// statusView.translateButtonContainer.addArrangedSubview(statusView.translateButton) +// statusView.translateButtonContainer.addArrangedSubview(UIView()) +// statusView.translateButton.setContentHuggingPriority(.required - 1, for: .vertical) +// statusView.translateButton.setContentCompressionResistancePriority(.required - 1, for: .vertical) +// +// // mediaGridContainerView +// statusView.containerStackView.addArrangedSubview(statusView.mediaGridContainerView) +// +// // pollTableView +// statusView.containerStackView.addArrangedSubview(statusView.pollTableView) +// +// statusView.pollVoteInfoContainerView.axis = .horizontal +// statusView.pollVoteInfoContainerView.addArrangedSubview(statusView.pollVoteDescriptionLabel) +// statusView.pollVoteInfoContainerView.addArrangedSubview(UIView()) // spacer +// statusView.pollVoteInfoContainerView.addArrangedSubview(statusView.pollVoteButton) +// statusView.pollVoteButton.setContentHuggingPriority(.required - 10, for: .horizontal) +// statusView.pollVoteButton.setContentCompressionResistancePriority(.required - 9, for: .horizontal) +// statusView.pollVoteInfoContainerView.addArrangedSubview(statusView.pollVoteActivityIndicatorView) +// statusView.pollVoteInfoContainerView.setContentHuggingPriority(.required - 11, for: .horizontal) +// statusView.pollVoteInfoContainerView.setContentCompressionResistancePriority(.required - 10, for: .horizontal) +// statusView.containerStackView.addArrangedSubview(statusView.pollVoteInfoContainerView) +// +// // quoteStatusView +// let quoteStatusView = StatusView() +// quoteStatusView.setup(style: .quote) +// statusView.quoteStatusView = quoteStatusView +// statusView.containerStackView.addArrangedSubview(quoteStatusView) +// +// // location content: H - [ padding | locationMapPinImageView | locationLabel | padding ] +// statusView.containerStackView.addArrangedSubview(statusView.locationContainer) +// +// statusView.locationMapPinImageView.translatesAutoresizingMaskIntoConstraints = false +// statusView.locationLabel.translatesAutoresizingMaskIntoConstraints = false +// +// // locationLeadingPadding +// let locationLeadingPadding = UIView() +// locationLeadingPadding.translatesAutoresizingMaskIntoConstraints = false +// statusView.locationContainer.addArrangedSubview(locationLeadingPadding) +// // locationMapPinImageView +// statusView.locationContainer.addArrangedSubview(statusView.locationMapPinImageView) +// // locationLabel +// statusView.locationContainer.addArrangedSubview(statusView.locationLabel) +// // locationTrailingPadding +// let locationTrailingPadding = UIView() +// locationTrailingPadding.translatesAutoresizingMaskIntoConstraints = false +// statusView.locationContainer.addArrangedSubview(locationTrailingPadding) +// +// // center alignment +// NSLayoutConstraint.activate([ +// locationLeadingPadding.widthAnchor.constraint(equalTo: locationTrailingPadding.widthAnchor).priority(.defaultHigh), +// ]) +// +// NSLayoutConstraint.activate([ +// statusView.locationMapPinImageView.heightAnchor.constraint(equalTo: statusView.locationLabel.heightAnchor, multiplier: 1.0).priority(.required - 1), +// statusView.locationMapPinImageView.widthAnchor.constraint(equalTo: statusView.locationMapPinImageView.heightAnchor, multiplier: 1.0).priority(.required - 1), +// ]) +// statusView.locationLabel.setContentHuggingPriority(.required - 10, for: .vertical) +// statusView.locationMapPinImageView.setContentHuggingPriority(.defaultLow, for: .vertical) +// statusView.locationMapPinImageView.setContentHuggingPriority(.defaultLow, for: .horizontal) +// +// // metrics +// statusView.containerStackView.addArrangedSubview(statusView.metricsDashboardView) +// +// UIContentSizeCategory.publisher +// .sink { category in +// if category >= .extraExtraLarge { +// statusView.metricsDashboardView.metaContainer.axis = .vertical +// } else { +// statusView.metricsDashboardView.metaContainer.axis = .horizontal +// } +// } +// .store(in: &statusView._disposeBag) +// +// // toolbar +// statusView.containerStackView.addArrangedSubview(statusView.toolbar) +// statusView.toolbar.setContentHuggingPriority(.required - 9, for: .vertical) +// +// // reply settings +// statusView.containerStackView.addArrangedSubview(statusView.replySettingBannerView) +// } +// +// private func layoutQuote(statusView: StatusView) { +// // container: V - [ body container ] +// // set `isLayoutMarginsRelativeArrangement` not works with AutoLayout (priority issue) +// // add constraint to workaround +// statusView.containerStackView.backgroundColor = .secondarySystemBackground +// statusView.containerStackView.layer.masksToBounds = true +// statusView.containerStackView.layer.cornerCurve = .continuous +// statusView.containerStackView.layer.cornerRadius = 12 +// +// // body container: V - [ author content | content container | contentTextView | mediaGridContainerView ] +// let bodyContainerStackView = UIStackView() +// bodyContainerStackView.axis = .vertical +// bodyContainerStackView.spacing = StatusView.bodyContainerStackViewSpacing +// bodyContainerStackView.translatesAutoresizingMaskIntoConstraints = false +// statusView.containerStackView.addSubview(bodyContainerStackView) +// NSLayoutConstraint.activate([ +// bodyContainerStackView.topAnchor.constraint(equalTo: statusView.containerStackView.topAnchor, constant: StatusView.quoteStatusViewContainerLayoutMargin).priority(.required - 1), +// bodyContainerStackView.leadingAnchor.constraint(equalTo: statusView.containerStackView.leadingAnchor, constant: StatusView.quoteStatusViewContainerLayoutMargin).priority(.required - 1), +// statusView.containerStackView.trailingAnchor.constraint(equalTo: bodyContainerStackView.trailingAnchor, constant: StatusView.quoteStatusViewContainerLayoutMargin).priority(.required - 1), +// statusView.containerStackView.bottomAnchor.constraint(equalTo: bodyContainerStackView.bottomAnchor, constant: StatusView.quoteStatusViewContainerLayoutMargin).priority(.required - 1), +// ]) +// +// // author content: H - [ authorAvatarButton | authorNameLabel | lockImageView | authorUsernameLabel | padding ] +// let authorContentStackView = UIStackView() +// authorContentStackView.axis = .horizontal +// bodyContainerStackView.alignment = .top +// authorContentStackView.spacing = 6 +// bodyContainerStackView.addArrangedSubview(authorContentStackView) +// bodyContainerStackView.setCustomSpacing(4, after: authorContentStackView) +// +// // authorAvatarButton +// statusView.authorAvatarButton.translatesAutoresizingMaskIntoConstraints = false +// authorContentStackView.addArrangedSubview(statusView.authorAvatarButton) +// // authorNameLabel +// authorContentStackView.addArrangedSubview(statusView.authorNameLabel) +// statusView.authorNameLabel.setContentCompressionResistancePriority(.required - 10, for: .horizontal) +// // lockImageView +// statusView.lockImageView.translatesAutoresizingMaskIntoConstraints = false +// authorContentStackView.addArrangedSubview(statusView.lockImageView) +// // authorUsernameLabel +// authorContentStackView.addArrangedSubview(statusView.authorUsernameLabel) +// NSLayoutConstraint.activate([ +// statusView.lockImageView.heightAnchor.constraint(equalTo: statusView.authorUsernameLabel.heightAnchor).priority(.required - 10), +// ]) +// statusView.lockImageView.setContentHuggingPriority(.defaultLow, for: .horizontal) +// statusView.lockImageView.setContentCompressionResistancePriority(.required - 10, for: .horizontal) +// statusView.authorUsernameLabel.setContentCompressionResistancePriority(.required - 11, for: .horizontal) +// // padding +// authorContentStackView.addArrangedSubview(UIView()) +// +// NSLayoutConstraint.activate([ +// statusView.authorAvatarButton.heightAnchor.constraint(equalTo: statusView.authorNameLabel.heightAnchor, multiplier: 1.0).priority(.required - 10), +// statusView.authorAvatarButton.widthAnchor.constraint(equalTo: statusView.authorNameLabel.heightAnchor, multiplier: 1.0).priority(.required - 10), +// ]) +// // low priority for intrinsic size hugging +// statusView.authorAvatarButton.setContentHuggingPriority(.defaultLow - 10, for: .vertical) +// statusView.authorAvatarButton.setContentHuggingPriority(.defaultLow - 10, for: .horizontal) +// // high priority but lower then layout constraint for size compression +// statusView.authorAvatarButton.setContentCompressionResistancePriority(.required - 11, for: .vertical) +// statusView.authorAvatarButton.setContentCompressionResistancePriority(.required - 11, for: .horizontal) +// statusView.authorNameLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical) +// statusView.authorNameLabel.setContentHuggingPriority(.required - 1, for: .vertical) +// +// // contentTextView +// statusView.contentTextView.translatesAutoresizingMaskIntoConstraints = false +// bodyContainerStackView.addArrangedSubview(statusView.contentTextView) +// statusView.contentTextView.setContentHuggingPriority(.required - 10, for: .vertical) +// statusView.contentTextView.textAttributes = [ +// .font: UIFont.preferredFont(forTextStyle: .callout), +// .foregroundColor: UIColor.secondaryLabel, +// ] +// statusView.contentTextView.linkAttributes = [ +// .font: UIFont.preferredFont(forTextStyle: .callout), +// .foregroundColor: Asset.Colors.Theme.daylight.color +// ] +// +// // mediaGridContainerView +// statusView.mediaGridContainerView.translatesAutoresizingMaskIntoConstraints = false +// bodyContainerStackView.addArrangedSubview(statusView.mediaGridContainerView) +// } +// +// func layoutComposeReply(statusView: StatusView) { +// // container: V - [ body container ] +// +// // body container: H - [ authorAvatarButton | content container ] +// let bodyContainerStackView = UIStackView() +// bodyContainerStackView.axis = .horizontal +// bodyContainerStackView.spacing = StatusView.bodyContainerStackViewSpacing +// bodyContainerStackView.alignment = .top +// statusView.containerStackView.addArrangedSubview(bodyContainerStackView) +// +// // authorAvatarButton +// let authorAvatarButtonSize = CGSize(width: 44, height: 44) +// statusView.authorAvatarButton.size = authorAvatarButtonSize +// statusView.authorAvatarButton.avatarImageView.imageViewSize = authorAvatarButtonSize +// statusView.authorAvatarButton.translatesAutoresizingMaskIntoConstraints = false +// bodyContainerStackView.addArrangedSubview(statusView.authorAvatarButton) +// NSLayoutConstraint.activate([ +// statusView.authorAvatarButton.widthAnchor.constraint(equalToConstant: authorAvatarButtonSize.width).priority(.required - 1), +// statusView.authorAvatarButton.heightAnchor.constraint(equalToConstant: authorAvatarButtonSize.height).priority(.required - 1), +// ]) +// +// // content container: V - [ author content | contentTextView | mediaGridContainerView | quoteStatusView | … | location content | toolbar ] +// let contentContainerView = UIStackView() +// contentContainerView.axis = .vertical +// contentContainerView.spacing = 10 +// bodyContainerStackView.addArrangedSubview(contentContainerView) +// +// // author content: H - [ authorNameLabel | lockImageView | authorUsernameLabel | padding | visibilityImageView (for Mastodon) | timestampLabel ] +// let authorContentStackView = UIStackView() +// authorContentStackView.axis = .horizontal +// authorContentStackView.spacing = 6 +// contentContainerView.addArrangedSubview(authorContentStackView) +// contentContainerView.setCustomSpacing(4, after: authorContentStackView) +// UIContentSizeCategory.publisher +// .sink { category in +// authorContentStackView.axis = category > .accessibilityLarge ? .vertical : .horizontal +// } +// .store(in: &statusView._disposeBag) +// +// // authorNameLabel +// authorContentStackView.addArrangedSubview(statusView.authorNameLabel) +// statusView.authorNameLabel.setContentCompressionResistancePriority(.required - 10, for: .horizontal) +// // lockImageView +// statusView.lockImageView.translatesAutoresizingMaskIntoConstraints = false +// authorContentStackView.addArrangedSubview(statusView.lockImageView) +// // authorUsernameLabel +// authorContentStackView.addArrangedSubview(statusView.authorUsernameLabel) +// NSLayoutConstraint.activate([ +// statusView.lockImageView.heightAnchor.constraint(equalTo: statusView.authorUsernameLabel.heightAnchor).priority(.required - 10), +// ]) +// statusView.lockImageView.setContentHuggingPriority(.defaultLow, for: .horizontal) +// statusView.lockImageView.setContentCompressionResistancePriority(.required - 10, for: .horizontal) +// statusView.authorUsernameLabel.setContentCompressionResistancePriority(.required - 11, for: .horizontal) +// // padding +// authorContentStackView.addArrangedSubview(UIView()) +// // visibilityImageView +// authorContentStackView.addArrangedSubview(statusView.visibilityImageView) +// statusView.visibilityImageView.setContentHuggingPriority(.required - 9, for: .horizontal) +// statusView.visibilityImageView.setContentCompressionResistancePriority(.required - 9, for: .horizontal) +// // timestampLabel +// authorContentStackView.addArrangedSubview(statusView.timestampLabel) +// statusView.timestampLabel.setContentHuggingPriority(.required - 9, for: .horizontal) +// statusView.timestampLabel.setContentCompressionResistancePriority(.required - 9, for: .horizontal) +// +// // spoilerContentTextView +// contentContainerView.addArrangedSubview(statusView.spoilerContentTextView) +// statusView.spoilerContentTextView.setContentCompressionResistancePriority(.required - 10, for: .vertical) +// statusView.spoilerContentTextView.setContentHuggingPriority(.required - 9, for: .vertical) +// +// contentContainerView.addArrangedSubview(statusView.expandContentButtonContainer) +// statusView.expandContentButton.translatesAutoresizingMaskIntoConstraints = false +// statusView.expandContentButtonContainer.addArrangedSubview(statusView.expandContentButton) +// statusView.expandContentButtonContainer.addArrangedSubview(UIView()) +// +// // contentTextView +// statusView.contentTextView.translatesAutoresizingMaskIntoConstraints = false +// contentContainerView.addArrangedSubview(statusView.contentTextView) +// statusView.contentTextView.setContentHuggingPriority(.required - 10, for: .vertical) +// +// // mediaGridContainerView +// statusView.mediaGridContainerView.translatesAutoresizingMaskIntoConstraints = false +// contentContainerView.addArrangedSubview(statusView.mediaGridContainerView) +// +// // quoteStatusView +// let quoteStatusView = StatusView() +// quoteStatusView.setup(style: .quote) +// statusView.quoteStatusView = quoteStatusView +// contentContainerView.addArrangedSubview(quoteStatusView) +// +// // location content: H - [ locationMapPinImageView | locationLabel ] +// contentContainerView.addArrangedSubview(statusView.locationContainer) +// +// statusView.locationMapPinImageView.translatesAutoresizingMaskIntoConstraints = false +// statusView.locationLabel.translatesAutoresizingMaskIntoConstraints = false +// +// // locationMapPinImageView +// statusView.locationContainer.addArrangedSubview(statusView.locationMapPinImageView) +// // locationLabel +// statusView.locationContainer.addArrangedSubview(statusView.locationLabel) +// +// NSLayoutConstraint.activate([ +// statusView.locationMapPinImageView.heightAnchor.constraint(equalTo: statusView.locationLabel.heightAnchor, multiplier: 1.0).priority(.required - 1), +// statusView.locationMapPinImageView.widthAnchor.constraint(equalTo: statusView.locationMapPinImageView.heightAnchor, multiplier: 1.0).priority(.required - 1), +// ]) +// statusView.locationLabel.setContentHuggingPriority(.required - 10, for: .vertical) +// statusView.locationMapPinImageView.setContentHuggingPriority(.defaultLow, for: .vertical) +// statusView.locationMapPinImageView.setContentHuggingPriority(.defaultLow, for: .horizontal) +// } +// +//} +// +//extension StatusView { +// +// private func update(theme: Theme) { +// headerIconImageView.tintColor = theme.accentColor +// } +// +// public func setHeaderDisplay() { +// headerContainerView.isHidden = false +// } +// +// public func setLockDisplay() { +// lockImageView.isHidden = false +// } +// +// public func setVisibilityDisplay() { +// visibilityImageView.isHidden = false +// } +// +// public func setSpoilerDisplay() { +// spoilerContentTextView.isHidden = false +// expandContentButtonContainer.isHidden = false +// } +// +// public func setTranslateButtonDisplay() { +// translateButtonContainer.isHidden = false +// } +// +// public func setMediaDisplay() { +// mediaGridContainerView.isHidden = false +// } +// +// public func setPollDisplay() { +// pollTableView.isHidden = false +// pollVoteInfoContainerView.isHidden = false +// } +// +// public func setQuoteDisplay() { +// quoteStatusView?.isHidden = false +// } +// +// public func setLocationDisplay() { +// locationContainer.isHidden = false +// } +// +// public func setReplySettingsDisplay() { +// replySettingBannerView.isHidden = false +// } +// +// // content text Width +// public var contentMaxLayoutWidth: CGFloat { +// let inset = contentLayoutInset +// return frame.width - inset.left - inset.right +// } +// +// public var contentLayoutInset: UIEdgeInsets { +// guard let style = style else { +// assertionFailure("Needs setup style before use") +// return .zero +// } +// +// switch style { +// case .inline, .composeReply: +// let left = authorAvatarButton.size.width + StatusView.bodyContainerStackViewSpacing +// return UIEdgeInsets(top: 0, left: left, bottom: 0, right: 0) +// case .plain: +// return .zero +// case .quote: +// let margin = StatusView.quoteStatusViewContainerLayoutMargin +// return UIEdgeInsets(top: 0, left: margin, bottom: 0, right: margin) +// } +// } +// +//} +// +//extension StatusView { +// @objc private func headerTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { +// logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") +// delegate?.statusView(self, headerDidPressed: headerContainerView) +// } +// +// @objc private func authorAvatarButtonDidPressed(_ sender: UIButton) { +// logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") +// delegate?.statusView(self, authorAvatarButtonDidPressed: authorAvatarButton) +// } +// +// @objc private func expandContentButtonDidPressed(_ sender: UIButton) { +// logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") +// delegate?.statusView(self, expandContentButtonDidPressed: sender) +// } +// +// @objc private func pollVoteButtonDidPressed(_ sender: UIButton) { +// logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") +// delegate?.statusView(self, pollVoteButtonDidPressed: sender) +// } +// +// @objc private func quoteStatusViewDidPressed(_ sender: UITapGestureRecognizer) { +// logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") +// guard let quoteStatusView = quoteStatusView else { return } +// delegate?.statusView(self, quoteStatusViewDidPressed: quoteStatusView) +// } +// +// @objc private func quoteAuthorAvatarButtonDidPressed(_ sender: UITapGestureRecognizer) { +// logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") +// guard let quoteStatusView = quoteStatusView else { return } +// delegate?.statusView(self, quoteStatusView: quoteStatusView, authorAvatarButtonDidPressed: quoteStatusView.authorAvatarButton) +// } +// +// @objc private func translateButtonDidPressed(_ sender: UIButton) { +// logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") +// delegate?.statusView(self, translateButtonDidPressed: sender) +// } +//} +// +//// MARK: - MetaTextAreaViewDelegate +//extension StatusView: MetaTextAreaViewDelegate { +// public func metaTextAreaView(_ metaTextAreaView: MetaTextAreaView, didSelectMeta meta: Meta) { +// logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public))") +// delegate?.statusView(self, metaTextAreaView: metaTextAreaView, didSelectMeta: meta) +// } +//} +// +//// MARK: - MediaGridContainerViewDelegate +//extension StatusView: MediaGridContainerViewDelegate { +// public func mediaGridContainerView(_ container: MediaGridContainerView, didTapMediaView mediaView: MediaView, at index: Int) { +// delegate?.statusView(self, mediaGridContainerView: container, didTapMediaView: mediaView, at: index) +// } +// +// public func mediaGridContainerView(_ container: MediaGridContainerView, toggleContentWarningOverlayViewDisplay contentWarningOverlayView: ContentWarningOverlayView) { +// delegate?.statusView(self, mediaGridContainerView: container, toggleContentWarningOverlayViewDisplay: contentWarningOverlayView) +// } +//} +// +//// MARK: - StatusToolbarDelegate +//extension StatusView: StatusToolbarDelegate { +// public func statusToolbar(_ statusToolbar: StatusToolbar, actionDidPressed action: StatusToolbar.Action, button: UIButton) { +// delegate?.statusView(self, statusToolbar: statusToolbar, actionDidPressed: action, button: button) +// } +// +// public func statusToolbar(_ statusToolbar: StatusToolbar, menuActionDidPressed action: StatusToolbar.MenuAction, menuButton button: UIButton) { +// delegate?.statusView(self, statusToolbar: statusToolbar, menuActionDidPressed: action, menuButton: button) +// } +//} +// +//// MARK: - StatusViewDelegate +//// relay for quoteStatsView +//extension StatusView: StatusViewDelegate { +// +// public func statusView(_ statusView: StatusView, headerDidPressed header: UIView) { +// assertionFailure() +// } +// +// public func statusView(_ statusView: StatusView, authorAvatarButtonDidPressed button: AvatarButton) { +// guard statusView === quoteStatusView else { +// assertionFailure() +// return +// } +// +// delegate?.statusView(self, quoteStatusView: statusView, authorAvatarButtonDidPressed: button) +// } +// +// public func statusView(_ statusView: StatusView, quoteStatusView: StatusView, authorAvatarButtonDidPressed button: AvatarButton) { +// assertionFailure() +// } +// +// public func statusView(_ statusView: StatusView, metaTextAreaView: MetaTextAreaView, didSelectMeta meta: Meta) { +// guard statusView === quoteStatusView else { +// assertionFailure() +// return +// } +// +// delegate?.statusView(self, quoteStatusView: statusView, metaTextAreaView: metaTextAreaView, didSelectMeta: meta) +// } +// +// public func statusView(_ statusView: StatusView, expandContentButtonDidPressed button: UIButton) { +// assertionFailure() +// } +// +// public func statusView(_ statusView: StatusView, quoteStatusView: StatusView, metaTextAreaView: MetaTextAreaView, didSelectMeta meta: Meta) { +// assertionFailure() +// } +// +// public func statusView(_ statusView: StatusView, mediaGridContainerView containerView: MediaGridContainerView, didTapMediaView mediaView: MediaView, at index: Int) { +// guard statusView === quoteStatusView else { +// assertionFailure() +// return +// } +// +// delegate?.statusView(self, quoteStatusView: statusView, mediaGridContainerView: containerView, didTapMediaView: mediaView, at: index) +// } +// +// public func statusView(_ statusView: StatusView, pollTableView tableView: UITableView, didSelectRowAt indexPath: IndexPath) { +// assertionFailure() +// } +// +// public func statusView(_ statusView: StatusView, pollVoteButtonDidPressed button: UIButton) { +// assertionFailure() +// } +// +// public func statusView(_ statusView: StatusView, quoteStatusView: StatusView, mediaGridContainerView containerView: MediaGridContainerView, didTapMediaView mediaView: MediaView, at index: Int) { +// assertionFailure() +// } +// +// public func statusView(_ statusView: StatusView, mediaGridContainerView containerView: MediaGridContainerView, toggleContentWarningOverlayViewDisplay contentWarningOverlayView: ContentWarningOverlayView) { +// assertionFailure() +// } +// +// public func statusView(_ statusView: StatusView, quoteStatusViewDidPressed quoteStatusView: StatusView) { +// assertionFailure() +// } +// +// public func statusView(_ statusView: StatusView, statusToolbar: StatusToolbar, actionDidPressed action: StatusToolbar.Action, button: UIButton) { +// assertionFailure() +// } +// +// public func statusView(_ statusView: StatusView, statusToolbar: StatusToolbar, menuActionDidPressed action: StatusToolbar.MenuAction, menuButton button: UIButton) { +// assertionFailure() +// } +// +// public func statusView(_ statusView: StatusView, translateButtonDidPressed button: UIButton) { +// assertionFailure() +// } +// +// public func statusView(_ statusView: StatusView, accessibilityActivate: Void) { +// assertionFailure() +// } +// +//} +// +//// MARK: - UITableViewDelegate +//extension StatusView: UITableViewDelegate { +// public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { +// logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): select \(indexPath.debugDescription)") +// +// switch tableView { +// case pollTableView: +// delegate?.statusView(self, pollTableView: tableView, didSelectRowAt: indexPath) +// default: +// assertionFailure() +// } +// } +//} +// +//// MARK: - UIGestureRecognizerDelegate +//extension StatusView: UIGestureRecognizerDelegate { +// public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { +// if let view = touch.view, view is AvatarButton { +// return false +// } +// +// return true +// } +//} diff --git a/TwidereSDK/Sources/TwidereUI/Content/TimestampLabelView.swift b/TwidereSDK/Sources/TwidereUI/Content/TimestampLabelView.swift new file mode 100644 index 00000000..b1b895d4 --- /dev/null +++ b/TwidereSDK/Sources/TwidereUI/Content/TimestampLabelView.swift @@ -0,0 +1,47 @@ +// +// TimestampLabelView.swift +// +// +// Created by MainasuK on 2023/2/21. +// + +import SwiftUI +import Combine +import Meta +import TwidereCore +import DateToolsSwift + +public struct TimestampLabelView: View { + + @ObservedObject public var viewModel: ViewModel + + public init(viewModel: TimestampLabelView.ViewModel) { + self.viewModel = viewModel + } + + public var body: some View { + TimelineView(.periodic(from: .now, by: 1.0)) { timeline in + let timeAgo = viewModel.timeAgo(now: timeline.date) + Text("\(timeAgo)") + .font(Font(TextStyle.statusTimestamp.font).monospacedDigit()) + .foregroundColor(Color(uiColor: TextStyle.statusTimestamp.textColor)) + } + } +} + +extension TimestampLabelView { + public class ViewModel: ObservableObject { + // input + public let timestamp: Date + + public init(timestamp: Date) { + self.timestamp = timestamp + // end init + } + + func timeAgo(now: Date) -> String { + return timestamp.shortTimeAgo(since: now) + } + + } // end class +} diff --git a/TwidereSDK/Sources/TwidereUI/Content/TrendView.swift b/TwidereSDK/Sources/TwidereUI/Content/TrendView.swift new file mode 100644 index 00000000..2eded997 --- /dev/null +++ b/TwidereSDK/Sources/TwidereUI/Content/TrendView.swift @@ -0,0 +1,245 @@ +// +// SwiftUIView.swift +// +// +// Created by MainasuK on 2023/5/15. +// + +import SwiftUI +import Combine +import Meta +import MetaTextKit +import TwitterSDK +import MastodonSDK +import Charts + +public struct TrendView: View { + + @ObservedObject public var viewModel: ViewModel + + public init(viewModel: ViewModel) { + self.viewModel = viewModel + } + + public var body: some View { + switch viewModel.kind { + case .twitter: + HStack { + VStack(alignment: .leading, spacing: .zero) { + titleLabel + } + .alignmentGuide(.listRowSeparatorLeading) { viewDimensions in + viewDimensions[.leading] + } + Spacer() + } + + case .mastodon: + HStack { + VStack(alignment: .leading, spacing: .zero) { + titleLabel + descriptionLabel + } + .alignmentGuide(.listRowSeparatorLeading) { viewDimensions in + viewDimensions[.leading] + } + Spacer() + HStack { + chartDescriptionLabel + chartView + .frame(width: 66, height: 40) + } + } + } + } +} + +extension TrendView { + var titleLabel: some View { + LabelRepresentable( + metaContent: viewModel.title, + textStyle: .searchTrendTitle, + setupLabel: { label in + label.setContentHuggingPriority(.defaultHigh, for: .horizontal) + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + } + ) + .fixedSize(horizontal: false, vertical: true) + } + + var descriptionLabel: some View { + LabelRepresentable( + metaContent: viewModel.description, + textStyle: .searchTrendSubtitle, + setupLabel: { label in + label.setContentHuggingPriority(.defaultHigh, for: .horizontal) + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + } + ) + .fixedSize(horizontal: false, vertical: true) + } + + var chartDescriptionLabel: some View { + Text(viewModel.chartDescription) + .font(.footnote) + .foregroundColor(.secondary) + } + + var chartLineGradient: LinearGradient { + LinearGradient( + gradient: Gradient ( + colors: [ + Color(uiColor: Asset.Colors.hightLight.color).opacity(0.5), + Color(uiColor: Asset.Colors.hightLight.color).opacity(0.0), + ] + ), + startPoint: .top, + endPoint: .bottom + ) + } + + var chartView: some View { + Group { + if let historyData = viewModel.historyData { + Chart(historyData, id: \.self) { data in + LineMark( + x: .value("Day", data.day), + y: .value("Accounts", Int(data.accounts) ?? 0) + ) + .interpolationMethod(.catmullRom) + + AreaMark( + x: .value("Day", data.day), + y: .value("Accounts", Int(data.accounts) ?? 0) + ) + .interpolationMethod(.catmullRom) + .foregroundStyle(chartLineGradient) + } + .chartLegend(.hidden) + .chartXAxis(.hidden) + .chartYAxis(.hidden) + } + } // Group + } +} + +extension TrendView { + public class ViewModel: ObservableObject { + + @Published public var viewLayoutFrame = ViewLayoutFrame() + + // input + public let kind: Kind + public let title: MetaContent + public let description: MetaContent + public let chartDescription: String + + // output + @Published var historyData: [Mastodon.Entity.History]? + + public init( + kind: Kind, + title: MetaContent, + description: MetaContent, + chartDescription: String, + viewLayoutFramePublisher: Published.Publisher? + ) { + self.kind = kind + self.title = title + self.description = description + self.chartDescription = chartDescription + // end init + + viewLayoutFramePublisher?.assign(to: &$viewLayoutFrame) + } + + } +} + +extension TrendView.ViewModel { + public enum Kind: Hashable { + case twitter + case mastodon + } +} + +extension TrendView.ViewModel { + public convenience init( + object: TrendObject, + viewLayoutFramePublisher: Published.Publisher? + ) { + switch object { + case .twitter(let trend): + self.init(trend: trend, viewLayoutFramePublisher: viewLayoutFramePublisher) + case .mastodon(let tag): + self.init(tag: tag, viewLayoutFramePublisher: viewLayoutFramePublisher) + } + } + + public convenience init( + trend: Twitter.Entity.Trend, + viewLayoutFramePublisher: Published.Publisher? + ) { + self.init( + kind: .twitter, + title: PlaintextMetaContent(string: "\(trend.name)"), + description: PlaintextMetaContent(string: ""), + chartDescription: "", + viewLayoutFramePublisher: viewLayoutFramePublisher + ) + } + + public convenience init( + tag: Mastodon.Entity.Tag, + viewLayoutFramePublisher: Published.Publisher? + ) { + self.init( + kind: .mastodon, + title: Meta.convert(document: .plaintext(string: "#" + tag.name)), + description: PlaintextMetaContent(string: L10n.Scene.Trends.accounts(tag.talkingPeopleCount ?? 0)), + chartDescription: tag.history?.first?.uses ?? " ", + viewLayoutFramePublisher: viewLayoutFramePublisher + ) + + self.historyData = tag.history + } +} + + +#if DEBUG +extension TrendView.ViewModel { + convenience init(kind: Kind) { + self.init( + kind: kind, + title: PlaintextMetaContent(string: "#Name"), + description: PlaintextMetaContent(string: "500 people talking"), + chartDescription: "123", + viewLayoutFramePublisher: nil + ) + + historyData = [ + Mastodon.Entity.History(day: Date(year: 2020, month: 5, day: 1), uses: "123", accounts: "12"), + Mastodon.Entity.History(day: Date(year: 2020, month: 5, day: 2), uses: "123", accounts: "23"), + Mastodon.Entity.History(day: Date(year: 2020, month: 5, day: 3), uses: "123", accounts: "33"), + Mastodon.Entity.History(day: Date(year: 2020, month: 5, day: 4), uses: "123", accounts: "23"), + Mastodon.Entity.History(day: Date(year: 2020, month: 5, day: 5), uses: "123", accounts: "13"), + Mastodon.Entity.History(day: Date(year: 2020, month: 5, day: 6), uses: "123", accounts: "23"), + Mastodon.Entity.History(day: Date(year: 2020, month: 5, day: 7), uses: "123", accounts: "53"), + ] + } +} +#endif + +#if canImport(SwiftUI) && DEBUG +struct TrendView_Previews: PreviewProvider { + static var previews: some View { + TrendView(viewModel: .init(kind: .twitter)) + .previewDisplayName("Twitter") + .previewLayout(.fixed(width: 475, height: 88)) + TrendView(viewModel: .init(kind: .mastodon)) + .previewDisplayName("Mastodon") + .previewLayout(.fixed(width: 475, height: 88)) + } +} +#endif + diff --git a/TwidereSDK/Sources/TwidereUI/Content/UserContentView+ViewModel.swift b/TwidereSDK/Sources/TwidereUI/Content/UserContentView+ViewModel.swift deleted file mode 100644 index 5cbda61c..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 6fca1efa..728586ca 100644 --- a/TwidereSDK/Sources/TwidereUI/Content/UserView+Configuration.swift +++ b/TwidereSDK/Sources/TwidereUI/Content/UserView+Configuration.swift @@ -9,196 +9,154 @@ import Foundation import Combine import SwiftUI import CoreDataStack -import TwidereCommon import TwidereCore import TwidereAsset import Meta import MastodonSDK -extension UserView { - public struct ConfigurationContext { - public let listMembershipViewModel: ListMembershipViewModel? - public let authenticationContext: AuthenticationContext? - - public init( - listMembershipViewModel: ListMembershipViewModel?, - authenticationContext: AuthenticationContext? - ) { - self.listMembershipViewModel = listMembershipViewModel - self.authenticationContext = authenticationContext - } - } -} - -extension UserView { - public func configure( - user: UserObject, - me: UserObject?, - notification: NotificationObject?, - configurationContext: ConfigurationContext - ) { - viewModel.authenticationContext = configurationContext.authenticationContext - - switch user { - case .twitter(let user): - configure(twitterUser: user) - case .mastodon(let user): - configure(mastodonUser: user) - } - - if let notification = notification { - configure(notification: notification) - } - - 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 - } - } - - public func configure(notification: NotificationObject) { - switch notification { - case .mastodon(let notification): - configure(mastodonNotification: notification) - } - } -} - -extension UserView { - private func configure(twitterUser user: TwitterUser) { - // platform - viewModel.platform = .twitter - // userIdentifier - viewModel.userIdentifier = .twitter(.init(id: user.id)) - // userAuthenticationContext - viewModel.userAuthenticationContext = user.twitterAuthentication.flatMap { - AuthenticationContext(authenticationIndex: $0.authenticationIndex, secret: AppSecret.default.secret) - } - // avatar - user.publisher(for: \.profileImageURL) - .map { _ in user.avatarImageURL() } - .assign(to: \.avatarImageURL, on: viewModel) - .store(in: &disposeBag) - // author name - user.publisher(for: \.name) - .map { PlaintextMetaContent(string: $0) } - .assign(to: \.name, on: viewModel) - .store(in: &disposeBag) - // author username - user.publisher(for: \.username) - .map { $0 as String? } - .assign(to: \.username, on: viewModel) - .store(in: &disposeBag) - // protected - user.publisher(for: \.protected) - .assign(to: \.protected, on: viewModel) - .store(in: &disposeBag) - // followersCount - user.publisher(for: \.followersCount) - .map { Int($0) } - .assign(to: \.followerCount, on: viewModel) - .store(in: &disposeBag) - } -} - -extension UserView { - private func configure(mastodonUser user: MastodonUser) { - // platform - viewModel.platform = .mastodon - // userIdentifier - viewModel.userIdentifier = .mastodon(.init(domain: user.domain, id: user.id)) - // userAuthenticationContext - viewModel.userAuthenticationContext = user.mastodonAuthentication.flatMap { - AuthenticationContext(authenticationIndex: $0.authenticationIndex, secret: AppSecret.default.secret) - } - // 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, on: viewModel) - .store(in: &disposeBag) - // author name - Publishers.CombineLatest( - user.publisher(for: \.displayName), - user.publisher(for: \.emojis) - ) - .map { _ in user.nameMetaContent } - .assign(to: \.name, on: viewModel) - .store(in: &disposeBag) - // author username - user.publisher(for: \.acct) - .map { _ in user.acctWithDomain as String? } - .assign(to: \.username, on: viewModel) - .store(in: &disposeBag) - // protected - user.publisher(for: \.locked) - .assign(to: \.protected, on: viewModel) - .store(in: &disposeBag) - // followersCount - user.publisher(for: \.followersCount) - .map { Int($0) } - .assign(to: \.followerCount, on: viewModel) - .store(in: &disposeBag) - } - - private func configure(mastodonNotification notification: MastodonNotification) { - let user = notification.account - let type = notification.notificationType - Publishers.CombineLatest( - user.publisher(for: \.displayName), - user.publisher(for: \.emojis) - ) - .map { _ in - guard let info = NotificationHeaderInfo(type: type, user: user) else { - return .none - } - return ViewModel.Header.notification(info: info) - } - .assign(to: \.header, on: viewModel) - .store(in: &disposeBag) - - switch type { - case .followRequest: - setFollowRequestControlDisplay() - default: - break - } - - notification.publisher(for: \.isFollowRequestBusy) - .assign(to: \.isFollowRequestBusy, on: viewModel) - .store(in: &disposeBag) - } -} - - +//extension UserView { +// public func configure( +// user: UserObject, +// me: UserObject?, +// notification: NotificationObject?, +// configurationContext: ConfigurationContext +// ) { +// switch user { +// case .twitter(let user): +// configure(twitterUser: user) +// case .mastodon(let user): +// configure(mastodonUser: user) +// } +// +//// if let notification = notification { +//// configure(notification: notification) +//// } +// +// viewModel.relationshipViewModel.user = user +// viewModel.relationshipViewModel.me = me +// default: +// break +// } +// } +// +//// public func configure(notification: NotificationObject) { +//// switch notification { +//// case .mastodon(let notification): +//// configure(mastodonNotification: notification) +//// } +//// } +//} +// +//extension UserView { +// private func configure(twitterUser user: TwitterUser) { +// // platform +// viewModel.platform = .twitter +// // userIdentifier +// viewModel.userIdentifier = .twitter(.init(id: user.id)) +// // userAuthenticationContext +// viewModel.userAuthenticationContext = user.twitterAuthentication.flatMap { +// AuthenticationContext(authenticationIndex: $0.authenticationIndex, secret: AppSecret.default.secret) +// } +// // avatar +// user.publisher(for: \.profileImageURL) +// .map { _ in user.avatarImageURL() } +// .assign(to: \.avatarImageURL, on: viewModel) +// .store(in: &disposeBag) +// // author name +// user.publisher(for: \.name) +// .map { PlaintextMetaContent(string: $0) } +// .assign(to: \.name, on: viewModel) +// .store(in: &disposeBag) +// // author username +// user.publisher(for: \.username) +// .map { $0 as String? } +// .assign(to: \.username, on: viewModel) +// .store(in: &disposeBag) +// // protected +// user.publisher(for: \.protected) +// .assign(to: \.protected, on: viewModel) +// .store(in: &disposeBag) +// // followersCount +// user.publisher(for: \.followersCount) +// .map { Int($0) } +// .assign(to: \.followerCount, on: viewModel) +// .store(in: &disposeBag) +// } +//} +// +//extension UserView { +// private func configure(mastodonUser user: MastodonUser) { +// // platform +// viewModel.platform = .mastodon +// // userIdentifier +// viewModel.userIdentifier = .mastodon(.init(domain: user.domain, id: user.id)) +// // userAuthenticationContext +// viewModel.userAuthenticationContext = user.mastodonAuthentication.flatMap { +// AuthenticationContext(authenticationIndex: $0.authenticationIndex, secret: AppSecret.default.secret) +// } +// // 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, on: viewModel) +// .store(in: &disposeBag) +// // author name +// Publishers.CombineLatest( +// user.publisher(for: \.displayName), +// user.publisher(for: \.emojis) +// ) +// .map { _ in user.nameMetaContent } +// .assign(to: \.name, on: viewModel) +// .store(in: &disposeBag) +// // author username +// user.publisher(for: \.acct) +// .map { _ in user.acctWithDomain as String? } +// .assign(to: \.username, on: viewModel) +// .store(in: &disposeBag) +// // protected +// user.publisher(for: \.locked) +// .assign(to: \.protected, on: viewModel) +// .store(in: &disposeBag) +// // followersCount +// user.publisher(for: \.followersCount) +// .map { Int($0) } +// .assign(to: \.followerCount, on: viewModel) +// .store(in: &disposeBag) +// } +// +// private func configure(mastodonNotification notification: MastodonNotification) { +// let user = notification.account +// let type = notification.notificationType +// Publishers.CombineLatest( +// user.publisher(for: \.displayName), +// user.publisher(for: \.emojis) +// ) +// .map { _ in +// guard let info = NotificationHeaderInfo(type: type, user: user) else { +// return .none +// } +// return ViewModel.Header.notification(info: info) +// } +// .assign(to: \.header, on: viewModel) +// .store(in: &disposeBag) +// +// switch type { +// case .followRequest: +// setFollowRequestControlDisplay() +// default: +// break +// } +// +// notification.publisher(for: \.isFollowRequestBusy) +// .assign(to: \.isFollowRequestBusy, on: viewModel) +// .store(in: &disposeBag) +// } +//} +// +// diff --git a/TwidereSDK/Sources/TwidereUI/Content/UserView+ViewModel.swift b/TwidereSDK/Sources/TwidereUI/Content/UserView+ViewModel.swift index d54a25f1..9fcbd7af 100644 --- a/TwidereSDK/Sources/TwidereUI/Content/UserView+ViewModel.swift +++ b/TwidereSDK/Sources/TwidereUI/Content/UserView+ViewModel.swift @@ -10,7 +10,6 @@ import UIKit import Combine import SwiftUI import CoreDataStack -import TwidereCommon import TwidereCore import TwidereAsset import Meta @@ -18,253 +17,473 @@ import MastodonSDK extension UserView { public final class ViewModel: ObservableObject { + var disposeBag = Set() var observations = Set() let relationshipViewModel = RelationshipViewModel() - @Published public var platform: Platform = .none - @Published public var authenticationContext: AuthenticationContext? // me - @Published public var userAuthenticationContext: AuthenticationContext? - - @Published public var header: Header = .none + // input + public let user: UserObject? + public let authContext: AuthContext? + public let kind: Kind + public weak var delegate: UserViewDelegate? - @Published public var userIdentifier: UserIdentifier? = nil - @Published public var avatarImageURL: URL? - @Published public var avatarBadge: AvatarBadge = .none - // TODO: verified | bot + public private(set) var notification: NotificationObject? - @Published public var name: MetaContent? = PlaintextMetaContent(string: " ") - @Published public var username: String? + // output - @Published public var protected: Bool = false + // user + @Published public var avatarURL: URL? + @Published public var avatarStyle = UserDefaults.shared.avatarStyle + + @Published public var name: MetaContent = PlaintextMetaContent(string: "") + @Published public var username: String = "" - @Published public var followerCount: Int? + @Published public var platform: Platform = .none + @Published public var isMyself: Bool = false + @Published public var protected: Bool = false + + // TODO: verified | bot + + // follow request + @Published public var isFollowRequestActionDisplay = false @Published public var isFollowRequestBusy = false + // 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 + + // notification count + @Published public var notificationBadgeCount: Int = 0 + + @Published public var isSelectable: Bool = true + @Published public var isSelect: Bool = false + + private init( + object user: UserObject?, + authContext: AuthContext?, + kind: Kind, + delegate: UserViewDelegate? + ) { + self.user = user + self.authContext = authContext + self.kind = kind + self.delegate = delegate + // end init + + 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: + break + case .mastodon(let notification): + self.isFollowRequestActionDisplay = notification.notificationType == .followRequest + notification.publisher(for: \.isFollowRequestBusy) + .assign(to: &$isFollowRequestBusy) + default: + break + } + + switch kind { + case .search: // follow + if let authContext = authContext, let user = user { + self.followButtonViewModel = .init(user: user, authContext: authContext) + } + default: + break + } + + // avatar style + UserDefaults.shared.publisher(for: \.avatarStyle) + .assign(to: &$avatarStyle) + + // 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 + } +} + +extension UserView.ViewModel { + public enum Kind: Hashable { + // headline: name | lock + // subheadline: username + // accessory: [ badge | menu ] + case account - @Published public var badgeCount: Int = 0 + // headline: name | lock | username + // subheadline: follower count + // accessory: follow button + case search - public enum Header { - case none - case notification(info: NotificationHeaderInfo) - } + // headline: name | lock + // subheadline: username + // accessory: none + case friend - public enum AvatarBadge { - case none - case platform - case user // verified | bot - } + // headline: name | lock + // subheadline: username + // accessory: none + case history - func prepareForReuse() { - avatarImageURL = nil - isFollowRequestBusy = false - } + // header: notification + // headline: name | lock | username + // subheadline: follower count + // accessory: [ followRquest accept and reject button ] + case notification(NotificationObject) - init() { - // 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) - } + // headline: name | lock + // subheadline: username + // accessory: checkmark button + case mentionPick + + // headline: name | lock | username + // subheadline: follower count + // accessory: membership menu (isMyList) + // menuActions: [ remove ] + case listMember(ListMembershipViewModel?) + + // headline: name | lock | username + // subheadline: follower count + // accessory: membership button + case addListMember(ListMembershipViewModel?) + + // headline: name | lock + // subheadline: username + // accessory: disclosureIndicator + case settingAccountSection + + // headline: name | lock + // subheadline: username + // accessory: none + case plain + } + + public enum AvatarBadge { + case none + case platform + case user // verified | bot + } + + public enum MenuAction: Hashable { + case openInNewWindowForAccount + case signOut + case removeListMember } } extension UserView.ViewModel { - public func bind(userView: UserView) { - // avatar - $avatarImageURL - .sink { url in - let configuration = AvatarImageView.Configuration(url: url) - userView.authorProfileAvatarView.avatarButton.avatarImageView.configure(configuration: configuration) - } - .store(in: &disposeBag) - Publishers.CombineLatest( - $avatarBadge, - $platform - ) - .sink { avatarBadge, platform in - switch avatarBadge { - case .none: - userView.authorProfileAvatarView.badge = .none - case .platform: - userView.authorProfileAvatarView.badge = { - switch platform { - case .none: return .none - case .twitter: return .circle(.twitter) - case .mastodon: return .circle(.mastodon) - } - }() - case .user: - userView.authorProfileAvatarView.badge = .none - } + var verticalMargin: CGFloat { + switch kind { + case .notification: return .zero + default: return 12.0 } - .store(in: &disposeBag) - // badge - // TODO: - // header - $header - .sink { header in - switch header { - case .none: - return - case .notification(let info): - userView.headerIconImageView.image = info.iconImage - userView.headerIconImageView.tintColor = info.iconImageTintColor - userView.headerTextLabel.setupAttributes(style: UserView.headerTextLabelStyle) - userView.headerTextLabel.configure(content: info.textMetaContent) - userView.setHeaderDisplay() - } - } - .store(in: &disposeBag) - // name - $name - .sink { content in - guard let content = content else { - userView.nameLabel.reset() - return - } - userView.nameLabel.configure(content: content) - } - .store(in: &disposeBag) - // username - $username - .map { username in - return username.flatMap { "@\($0)" } ?? " " - } - .assign(to: \.text, on: userView.usernameLabel) - .store(in: &disposeBag) - // protected - $protected - .map { !$0 } - .assign(to: \.isHidden, on: userView.lockImageView) - .store(in: &disposeBag) - // follower count - $followerCount - .sink { followerCount in - let count = followerCount.flatMap { String($0) } ?? "-" - userView.followerCountLabel.text = L10n.Common.Controls.ProfileDashboard.followers + ": " + count - } - .store(in: &disposeBag) - // relationship - relationshipViewModel.$optionSet - .map { $0?.relationship(except: [.muting]) } - .sink { relationship in - guard let relationship = relationship else { return } - userView.friendshipButton.configure(relationship: relationship) - userView.friendshipButton.isHidden = relationship == .isMyself - } - .store(in: &disposeBag) + } + + var isSeparateLineDisplay: Bool { + switch kind { + case .notification: return false + case .settingAccountSection: return false + case .plain: return false + case .mentionPick: return false + default: return true + } + } +} +//extension UserView.ViewModel { +// public func bind(userView: UserView) { +// // avatar +// $avatarImageURL +// .sink { url in +// let configuration = AvatarImageView.Configuration(url: url) +// userView.authorProfileAvatarView.avatarButton.avatarImageView.configure(configuration: configuration) +// } +// .store(in: &disposeBag) +// Publishers.CombineLatest( +// $avatarBadge, +// $platform +// ) +// .sink { avatarBadge, platform in +// switch avatarBadge { +// case .none: +// userView.authorProfileAvatarView.badge = .none +// case .platform: +// userView.authorProfileAvatarView.badge = { +// switch platform { +// case .none: return .none +// case .twitter: return .circle(.twitter) +// case .mastodon: return .circle(.mastodon) +// } +// }() +// case .user: +// userView.authorProfileAvatarView.badge = .none +// } +// } +// .store(in: &disposeBag) +// // badge +// // TODO: +// // header +// $header +// .sink { header in +// switch header { +// case .none: +// return +// case .notification(let info): +// userView.headerIconImageView.image = info.iconImage +// userView.headerIconImageView.tintColor = info.iconImageTintColor +// userView.headerTextLabel.setupAttributes(style: UserView.headerTextLabelStyle) +// userView.headerTextLabel.configure(content: info.textMetaContent) +// userView.setHeaderDisplay() +// } +// } +// .store(in: &disposeBag) +// // name +// $name +// .sink { content in +// guard let content = content else { +// userView.nameLabel.reset() +// return +// } +// userView.nameLabel.configure(content: content) +// } +// .store(in: &disposeBag) +// // username +// $username +// .map { username in +// return username.flatMap { "@\($0)" } ?? " " +// } +// .assign(to: \.text, on: userView.usernameLabel) +// .store(in: &disposeBag) +// // protected +// $protected +// .map { !$0 } +// .assign(to: \.isHidden, on: userView.lockImageView) +// .store(in: &disposeBag) +// // follower count +// $followerCount +// .sink { followerCount in +// let count = followerCount.flatMap { String($0) } ?? "-" +// userView.followerCountLabel.text = L10n.Common.Controls.ProfileDashboard.followers + ": " + count +// } +// .store(in: &disposeBag) +// // relationship +// relationshipViewModel.$optionSet +// .map { $0?.relationship(except: [.muting]) } +// .sink { relationship in +// guard let relationship = relationship else { return } +// userView.friendshipButton.configure(relationship: relationship) +// userView.friendshipButton.isHidden = relationship == .isMyself +// } +// .store(in: &disposeBag) +// +// // accessory +// +// case .notification: +// $isFollowRequestBusy +// .sink { isFollowRequestBusy in +// userView.acceptFollowRequestButton.isHidden = isFollowRequestBusy +// userView.rejectFollowRequestButton.isHidden = isFollowRequestBusy +// userView.activityIndicatorView.isHidden = !isFollowRequestBusy +// userView.activityIndicatorView.startAnimating() +// } +// .store(in: &disposeBag) +// +// default: +// userView.menuButton.showsMenuAsPrimaryAction = true +// userView.menuButton.menu = nil +// } +// } +// +//} - // 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 - .sink { isFollowRequestBusy in - userView.acceptFollowRequestButton.isHidden = isFollowRequestBusy - userView.rejectFollowRequestButton.isHidden = isFollowRequestBusy - userView.activityIndicatorView.isHidden = !isFollowRequestBusy - userView.activityIndicatorView.startAnimating() - } - .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 +extension UserView.ViewModel { + public convenience init?( + user: UserObject?, + authContext: AuthContext?, + kind: Kind, + delegate: UserViewDelegate? + ) { + switch user { + case .twitter(let object): + self.init( + user: object, + authContext: authContext, + kind: kind, + delegate: delegate + ) + case .mastodon(let object): + self.init( + user: object, + authContext: authContext, + kind: kind, + delegate: delegate ) - .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 + return nil } + // end init + } +} + +extension UserView.ViewModel { + public convenience init( + user: TwitterUser, + authContext: AuthContext?, + kind: Kind, + delegate: UserViewDelegate? + ) { + self.init( + object: .twitter(object: user), + authContext: authContext, + kind: kind, + delegate: delegate + ) + // end init + + // user + platform = .twitter + user.publisher(for: \.profileImageURL) + .map { _ in user.avatarImageURL() } + .assign(to: &$avatarURL) + user.publisher(for: \.name) + .map { PlaintextMetaContent(string: $0) } + .assign(to: &$name) + user.publisher(for: \.username) + .assign(to: &$username) + user.publisher(for: \.protected) + .assign(to: &$protected) } + public convenience init( + user: MastodonUser, + authContext: AuthContext?, + kind: Kind, + delegate: UserViewDelegate? + ) { + self.init( + object: .mastodon(object: user), + authContext: authContext, + kind: kind, + delegate: delegate + ) + // end init + + // user + platform = .mastodon + user.publisher(for: \.avatar) + .compactMap { $0.flatMap { URL(string: $0) } } + .assign(to: &$avatarURL) + user.publisher(for: \.displayName) + .compactMap { _ in user.nameMetaContent } + .assign(to: &$name) + user.publisher(for: \.username) + .map { _ in user.acctWithDomain } + .assign(to: &$username) + user.publisher(for: \.locked) + .assign(to: &$protected) + } +} + +extension UserView.ViewModel { + public convenience init( + item: MentionPickViewModel.Item, + delegate: UserViewDelegate? + ) { + self.init( + object: nil, + authContext: nil, + kind: .mentionPick, + delegate: delegate + ) + // end init + + // user + switch item { + case .twitterUser(let username, let attribute): + self.avatarURL = attribute.avatarImageURL + self.name = PlaintextMetaContent(string: attribute.name ?? "") + self.username = username + self.isSelectable = !attribute.disabled + self.isSelect = attribute.selected + } // switch + } +} + +#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 + protected = true + } } +#endif diff --git a/TwidereSDK/Sources/TwidereUI/Content/UserView.swift b/TwidereSDK/Sources/TwidereUI/Content/UserView.swift index e24dde5a..3c1f2017 100644 --- a/TwidereSDK/Sources/TwidereUI/Content/UserView.swift +++ b/TwidereSDK/Sources/TwidereUI/Content/UserView.swift @@ -8,580 +8,920 @@ import os.log import UIKit +import SwiftUI import Combine import MetaTextKit +import MetaLabel import TwidereCore +import Kingfisher -protocol UserViewDelegate: AnyObject { - // func userView(_ userView: UserView, authorAvatarButtonDidPressed button: AvatarButton) - func userView(_ userView: UserView, menuActionDidPressed action: UserView.MenuAction, menuButton button: UIButton) - func userView(_ userView: UserView, friendshipButtonDidPressed button: UIButton) - func userView(_ userView: UserView, membershipButtonDidPressed button: UIButton) - func userView(_ userView: UserView, acceptFollowReqeustButtonDidPressed button: UIButton) - func userView(_ userView: UserView, rejectFollowReqeustButtonDidPressed button: UIButton) +public protocol UserViewDelegate: AnyObject { + func userView(_ viewModel: UserView.ViewModel, userAvatarButtonDidPressed user: UserRecord) + func userView(_ viewModel: UserView.ViewModel, menuActionDidPressed action: UserView.ViewModel.MenuAction) + func userView(_ viewModel: UserView.ViewModel, listMembershipButtonDidPressed user: UserRecord) + func userView(_ viewModel: UserView.ViewModel, followReqeustButtonDidPressed user: UserRecord, accept: Bool) } -public final class UserView: UIView { - - let logger = Logger(subsystem: "UserView", category: "UI") - - public static let avatarImageViewSize = CGSize(width: 44, height: 44) - - private var _disposeBag = Set() // which lifetime same to view scope - var disposeBag = Set() // clear when reuse - - weak var delegate: UserViewDelegate? - - private(set) var style: Style? - - public private(set) lazy var viewModel: ViewModel = { - let viewModel = ViewModel() - viewModel.bind(userView: self) - return viewModel - }() - - // container - public let containerStackView: UIStackView = { - let stackView = UIStackView() - stackView.axis = .vertical - stackView.spacing = 8 - return stackView - }() - - public static var contentStackViewSpacing: CGFloat = 10 - public let contentStackView: UIStackView = { - let stackView = UIStackView() - stackView.axis = .horizontal - stackView.spacing = UserView.contentStackViewSpacing - stackView.alignment = .center - return stackView - }() - - public let infoContainerStackView: UIStackView = { - let stackView = UIStackView() - stackView.axis = .vertical - stackView.distribution = .fillEqually - return stackView - }() - - public let accessoryContainerView: UIStackView = { - let stackView = UIStackView() - stackView.axis = .horizontal - stackView.alignment = .firstBaseline - stackView.spacing = 8 - return stackView - }() - - // header - public let headerContainerView = UIView() - public let headerIconImageView = UIImageView() - public static var headerTextLabelStyle: TextStyle { .statusHeader } - public let headerTextLabel = MetaLabel(style: .statusHeader) - - // avatar - public let authorProfileAvatarView: ProfileAvatarView = { - let profileAvatarView = ProfileAvatarView() - profileAvatarView.setup(dimension: .inline) - return profileAvatarView - }() - - // name - public let nameLabel = MetaLabel(style: .userAuthorName) - - // username - public let usernameLabel = PlainLabel(style: .userAuthorUsername) - - // lock - public let lockImageView: UIImageView = { - let imageView = UIImageView() - imageView.tintColor = .secondaryLabel - imageView.contentMode = .scaleAspectFill - imageView.image = Asset.ObjectTools.lockMiniInline.image.withRenderingMode(.alwaysTemplate) - return imageView - }() - - // followerCount - public let followerCountLabel = PlainLabel(style: .userDescription) - - // friendship control - public let friendshipButton: FriendshipButton = { - let button = FriendshipButton() - button.titleFont = UIFontMetrics(forTextStyle: .headline) - .scaledFont(for: UIFont.systemFont(ofSize: 13, weight: .semibold)) - return button - }() - - // checkmark control - public let checkmarkButton: UIButton = { - let button = UIButton() - button.setImage(UIImage(systemName: "checkmark.circle.fill"), for: .normal) - button.tintColor = Asset.Colors.hightLight.color - return button - }() - - // menu control - public let menuButton: HitTestExpandedButton = { - let button = HitTestExpandedButton() - button.setImage(UIImage(systemName: "ellipsis.circle"), for: .normal) - button.tintColor = Asset.Colors.hightLight.color - 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() - - public private(set) lazy var acceptFollowRequestButton: HitTestExpandedButton = { - let button = HitTestExpandedButton() - button.setImage(Asset.Indices.checkmarkCircle.image.withRenderingMode(.alwaysTemplate), for: .normal) - button.tintColor = Asset.Colors.hightLight.color // FIXME: tint color - button.addTarget(self, action: #selector(UserView.acceptFollowRequestButtonDidPressed(_:)), for: .touchUpInside) - button.accessibilityLabel = L10n.Common.Notification.FollowRequestAction.approve - return button - }() - - public private(set) lazy var rejectFollowRequestButton: HitTestExpandedButton = { - let button = HitTestExpandedButton() - button.setImage(Asset.Indices.xmarkCircle.image.withRenderingMode(.alwaysTemplate), for: .normal) - button.tintColor = .secondaryLabel - button.addTarget(self, action: #selector(UserView.rejectFollowRequestButtonDidPressed(_:)), for: .touchUpInside) - button.accessibilityLabel = L10n.Common.Notification.FollowRequestAction.deny - return button - }() - - // activity indicator - public let activityIndicatorView: UIActivityIndicatorView = { - let activityIndicatorView = UIActivityIndicatorView(style: .medium) - activityIndicatorView.hidesWhenStopped = true - activityIndicatorView.startAnimating() - return activityIndicatorView - }() +public struct UserView: View { - // badge - public let badgeImageView: UIImageView = { - let imageView = UIImageView() - imageView.contentMode = .scaleAspectFit - imageView.tintColor = .label - return imageView - }() + @ObservedObject public private(set) var viewModel: ViewModel - public func prepareForReuse() { - disposeBag.removeAll() - viewModel.prepareForReuse() - authorProfileAvatarView.avatarButton.avatarImageView.cancelTask() - Style.prepareForReuse(userView: self) - } + @Environment(\.dynamicTypeSize) var dynamicTypeSize + @ScaledMetric(relativeTo: .headline) private var lockImageDimension: CGFloat = 16 - public override init(frame: CGRect) { - super.init(frame: frame) - _init() + public init(viewModel: UserView.ViewModel) { + self.viewModel = viewModel } - public required init?(coder: NSCoder) { - super.init(coder: coder) - _init() + public var body: some View { + Group { + if dynamicTypeSize < .accessibility1 { + HStack(alignment: .center, spacing: .zero) { + // avatar + avatarButton + .padding(.trailing, StatusView.hangingAvatarButtonTrailingSpacing) + // info + VStackLayout(alignment: .leading, spacing: .zero) { + headlineView + subheadlineView + } + .frame(alignment: .leading) + Spacer() + // accessory view + accessoryView + } // end HStack + .padding(.vertical, viewModel.verticalMargin) + .overlay { + if viewModel.isSeparateLineDisplay { + HStack(spacing: .zero) { + Color.clear.frame(width: StatusView.hangingAvatarButtonDimension + StatusView.hangingAvatarButtonTrailingSpacing) + VStack(spacing: .zero) { + Spacer() + Divider() + Color.clear.frame(height: 1) + } + } // end HStack + } // end if + } // end .overlay + } else { + VStack(spacing: .zero) { + HStack { + // avatar + avatarButton + .padding(.trailing, StatusView.hangingAvatarButtonTrailingSpacing) + Spacer() + // accessory view + accessoryView + } + // info + VStackLayout(alignment: .leading, spacing: .zero) { + headlineView + subheadlineView + } + .frame(alignment: .leading) + } // end HStack + .padding(.vertical, viewModel.verticalMargin) + .overlay { + if viewModel.isSeparateLineDisplay { + HStack(spacing: .zero) { + VStack(spacing: .zero) { + Spacer() + Divider() + Color.clear.frame(height: 1) + } + } // end HStack + } // end if + } // end .overlay + } + } // Group } - } extension UserView { - public enum MenuAction: Hashable { - case signOut - case remove + var allowsAvatarButtonHitTesting: Bool { + switch viewModel.kind { + case .account: return false + default: return true + } } -} + var avatarButton: some View { + Button { + 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) + } + } -extension UserView { - private func _init() { - containerStackView.translatesAutoresizingMaskIntoConstraints = false - addSubview(containerStackView) - NSLayoutConstraint.activate([ - containerStackView.topAnchor.constraint(equalTo: topAnchor), - containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor), - trailingAnchor.constraint(equalTo: containerStackView.trailingAnchor), - bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor), - ]) - - // container:: V - [ header container | content ] - containerStackView.addArrangedSubview(headerContainerView) - containerStackView.addArrangedSubview(contentStackView) - - // content: H - [ user avatar | info container | accessory container ] - authorProfileAvatarView.translatesAutoresizingMaskIntoConstraints = false - contentStackView.addArrangedSubview(authorProfileAvatarView) - NSLayoutConstraint.activate([ - authorProfileAvatarView.widthAnchor.constraint(equalToConstant: UserView.avatarImageViewSize.width).priority(.required - 1), - authorProfileAvatarView.heightAnchor.constraint(equalToConstant: UserView.avatarImageViewSize.height).priority(.required - 1), - ]) - contentStackView.addArrangedSubview(infoContainerStackView) - contentStackView.addArrangedSubview(accessoryContainerView) - - authorProfileAvatarView.isUserInteractionEnabled = false - nameLabel.isUserInteractionEnabled = false - usernameLabel.isUserInteractionEnabled = false - followerCountLabel.isUserInteractionEnabled = false - - membershipButton.addTarget(self, action: #selector(UserView.membershipButtonDidPressed(_:)), for: .touchUpInside) - - #if DEBUG - nameLabel.configure(content: PlaintextMetaContent(string: "Name")) - usernameLabel.text = "@username" - followerCountLabel.text = "1000 Followers" - #endif + default: + avatarButtonContentView + } + } + .buttonStyle(.borderless) + .allowsHitTesting(allowsAvatarButtonHitTesting) } - public func setup(style: Style) { - guard self.style == nil else { - assertionFailure("Should only setup once") - return + var avatarButtonContentView: some View { + Group { + let dimension: CGFloat = StatusView.hangingAvatarButtonDimension + KFImage(viewModel.avatarURL) + .placeholder { progress in + Color(uiColor: .placeholderText) + } + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: dimension, height: dimension) + .clipShape(AvatarClipShape(avatarStyle: viewModel.avatarStyle)) + .animation(.easeInOut, value: viewModel.avatarStyle) } - self.style = style - style.layout(userView: self) - Style.prepareForReuse(userView: self) - } -} - -extension UserView { - - @objc private func membershipButtonDidPressed(_ sender: UIButton) { - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") - delegate?.userView(self, membershipButtonDidPressed: sender) } - @objc private func acceptFollowRequestButtonDidPressed(_ sender: UIButton) { - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") - delegate?.userView(self, acceptFollowReqeustButtonDidPressed: sender) - } - - @objc private func rejectFollowRequestButtonDidPressed(_ sender: UIButton) { - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") - delegate?.userView(self, rejectFollowReqeustButtonDidPressed: sender) + var nameLabel: some View { + LabelRepresentable( + metaContent: viewModel.name, + textStyle: .statusAuthorName, + setupLabel: { label in + label.setContentHuggingPriority(.defaultHigh, for: .horizontal) + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + } + ) + .fixedSize(horizontal: false, vertical: true) } -} - -extension UserView { - public enum Style { - // headline: name | lock - // subheadline: username - // accessory: [ badge | menu ] - case account - - // headline: name | lock | username - // subheadline: follower count - // accessory: follow button - case relationship - - // headline: name | lock - // subheadline: username - // accessory: action button - case friendship - - // header: notification - // headline: name | lock | username - // subheadline: follower count - // accessory: [ followRquest accept and reject button ] - case notification - - // headline: name | lock - // subheadline: username - // accessory: checkmark button - case mentionPick - - // headline: name | lock | username - // subheadline: follower count - // accessory: membership menu - case listMember - - // headline: name | lock | username - // subheadline: follower count - // accessory: membership button - case addListMember - - public func layout(userView: UserView) { - switch self { - case .account: layoutAccount(userView: userView) - case .relationship: layoutRelationship(userView: userView) - case .friendship: layoutFriendship(userView: userView) - case .notification: layoutNotification(userView: userView) - case .mentionPick: layoutMentionPick(userView: userView) - case .listMember: layoutListMember(userView: userView) - case .addListMember: layoutAddListMember(userView: userView) + var usernameLabel: some View { + LabelRepresentable( + metaContent: { + guard !viewModel.username.isEmpty else { return PlaintextMetaContent(string: "") } + return PlaintextMetaContent(string: "@" + viewModel.username) + }(), + textStyle: .statusAuthorUsername, + setupLabel: { label in + label.setContentHuggingPriority(.defaultHigh, for: .horizontal) + label.setContentCompressionResistancePriority(.defaultLow - 100, for: .horizontal) } - } - - public static func prepareForReuse(userView: UserView) { - userView.headerContainerView.isHidden = true - userView.followRequestControlContainerView.isHidden = true - } + ) + .fixedSize(horizontal: false, vertical: true) } -} - -extension UserView.Style { - // headline: name | lock | username - // subheadline: follower count - private func layoutRelationshipBase(userView: UserView) { - let headlineStackView = UIStackView() - userView.infoContainerStackView.addArrangedSubview(headlineStackView) - headlineStackView.axis = .horizontal - headlineStackView.spacing = 6 - headlineStackView.addArrangedSubview(userView.nameLabel) - userView.lockImageView.translatesAutoresizingMaskIntoConstraints = false - headlineStackView.addArrangedSubview(userView.lockImageView) - NSLayoutConstraint.activate([ - userView.lockImageView.heightAnchor.constraint(equalTo: userView.nameLabel.heightAnchor).priority(.required - 10), - ]) - userView.lockImageView.setContentHuggingPriority(.defaultLow, for: .horizontal) - userView.lockImageView.setContentCompressionResistancePriority(.required - 10, for: .horizontal) - headlineStackView.addArrangedSubview(UIView()) // padding - - userView.infoContainerStackView.addArrangedSubview(userView.usernameLabel) + var menuView: some View { + 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) + } label: { + Label { + Text(L10n.Common.Controls.Actions.signOut) + } icon: { + 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() + } + } label: { + Image(systemName: "ellipsis.circle") + .padding() + } } - // FIXME: update layout - func layoutAccount(userView: UserView) { - let headlineStackView = UIStackView() - userView.infoContainerStackView.addArrangedSubview(headlineStackView) - headlineStackView.axis = .horizontal - headlineStackView.spacing = 6 - headlineStackView.addArrangedSubview(userView.nameLabel) - userView.lockImageView.translatesAutoresizingMaskIntoConstraints = false - headlineStackView.addArrangedSubview(userView.lockImageView) - NSLayoutConstraint.activate([ - userView.lockImageView.heightAnchor.constraint(equalTo: userView.nameLabel.heightAnchor).priority(.required - 10), - ]) - userView.lockImageView.setContentHuggingPriority(.defaultLow, for: .horizontal) - userView.lockImageView.setContentCompressionResistancePriority(.required - 10, for: .horizontal) - headlineStackView.addArrangedSubview(UIView()) // padding - - userView.infoContainerStackView.addArrangedSubview(userView.usernameLabel) - - userView.accessoryContainerView.addArrangedSubview(userView.badgeImageView) - userView.accessoryContainerView.addArrangedSubview(userView.menuButton) - userView.badgeImageView.setContentHuggingPriority(.required - 2, for: .horizontal) - userView.badgeImageView.setContentCompressionResistancePriority(.required - 1, for: .vertical) - userView.menuButton.setContentHuggingPriority(.required - 1, for: .horizontal) - - userView.setNeedsLayout() + var membershipButton: some View { + Button { + guard !viewModel.isListMemberCandidate else { return } + guard let user = viewModel.user?.asRecord else { return } + viewModel.delegate?.userView(viewModel, listMembershipButtonDidPressed: user) + } label: { + 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 + } } - // FIXME: update layout - func layoutRelationship(userView: UserView) { - layoutRelationshipBase(userView: userView) - - userView.friendshipButton.translatesAutoresizingMaskIntoConstraints = false - userView.accessoryContainerView.addArrangedSubview(userView.friendshipButton) - NSLayoutConstraint.activate([ -// userView.friendshipButton.heightAnchor.constraint(equalToConstant: 34).priority(.required - 1), - userView.friendshipButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 80).priority(.required - 1), - ]) - userView.friendshipButton.setContentHuggingPriority(.required - 10, for: .horizontal) - userView.friendshipButton.setContentCompressionResistancePriority(.required - 10, for: .horizontal) - - userView.setNeedsLayout() + var followRequestActionView: some View { + HStack(spacing: .zero) { + Button { + guard !viewModel.isFollowRequestBusy else { return } + 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 } + 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() + } + .tint(.secondary) + } // end HStack + .opacity(viewModel.isFollowRequestBusy ? 0 : 1) + .overlay(alignment: .trailing) { + if viewModel.isFollowRequestBusy { + ProgressView() + .progressViewStyle(.circular) + } + } } - func layoutFriendship(userView: UserView) { - // headline - let headlineStackView = UIStackView() - userView.infoContainerStackView.addArrangedSubview(headlineStackView) - headlineStackView.axis = .horizontal - headlineStackView.spacing = 6 - headlineStackView.addArrangedSubview(userView.nameLabel) - userView.lockImageView.translatesAutoresizingMaskIntoConstraints = false - headlineStackView.addArrangedSubview(userView.lockImageView) - NSLayoutConstraint.activate([ - userView.lockImageView.heightAnchor.constraint(equalTo: userView.nameLabel.heightAnchor).priority(.required - 10), - ]) - userView.lockImageView.setContentHuggingPriority(.defaultLow, for: .horizontal) - userView.lockImageView.setContentCompressionResistancePriority(.required - 10, for: .horizontal) - headlineStackView.addArrangedSubview(userView.usernameLabel) - headlineStackView.addArrangedSubview(UIView()) // padding - - // subheadline - userView.infoContainerStackView.addArrangedSubview(userView.followerCountLabel) - - // TODO: menu - - userView.setNeedsLayout() + var notificationBadgeCountView: some View { + Group { + let count = max(0, min(viewModel.notificationBadgeCount, 50)) + Image(systemName: "\(count).circle.fill") + } } - func layoutNotification(userView: UserView) { - userView.headerIconImageView.translatesAutoresizingMaskIntoConstraints = false - userView.headerTextLabel.translatesAutoresizingMaskIntoConstraints = false - userView.headerContainerView.addSubview(userView.headerIconImageView) - userView.headerContainerView.addSubview(userView.headerTextLabel) - NSLayoutConstraint.activate([ - userView.headerTextLabel.topAnchor.constraint(equalTo: userView.headerContainerView.topAnchor), - userView.headerTextLabel.bottomAnchor.constraint(equalTo: userView.headerContainerView.bottomAnchor), - userView.headerTextLabel.trailingAnchor.constraint(equalTo: userView.headerContainerView.trailingAnchor), - userView.headerIconImageView.centerYAnchor.constraint(equalTo: userView.headerTextLabel.centerYAnchor), - userView.headerIconImageView.heightAnchor.constraint(equalTo: userView.headerTextLabel.heightAnchor, multiplier: 1.0).priority(.required - 1), - userView.headerIconImageView.widthAnchor.constraint(equalTo: userView.headerIconImageView.heightAnchor, multiplier: 1.0).priority(.required - 1), - userView.headerTextLabel.leadingAnchor.constraint(equalTo: userView.headerIconImageView.trailingAnchor, constant: 4), - // align to author name below - ]) - userView.headerTextLabel.setContentHuggingPriority(.required - 10, for: .vertical) - userView.headerIconImageView.setContentHuggingPriority(.defaultLow, for: .vertical) - userView.headerIconImageView.setContentHuggingPriority(.defaultLow, for: .horizontal) - - layoutRelationshipBase(userView: userView) - - // set header label align to author name - NSLayoutConstraint.activate([ - userView.headerTextLabel.leadingAnchor.constraint(equalTo: userView.authorProfileAvatarView.trailingAnchor, constant: UserView.contentStackViewSpacing), - ]) - - // follow request button - userView.accessoryContainerView.addArrangedSubview(userView.followRequestControlContainerView) - userView.followRequestControlContainerView.axis = .horizontal - userView.followRequestControlContainerView.spacing = 20 - userView.followRequestControlContainerView.isHidden = true - - userView.followRequestControlContainerView.addArrangedSubview(userView.acceptFollowRequestButton) - userView.followRequestControlContainerView.addArrangedSubview(userView.rejectFollowRequestButton) - userView.acceptFollowRequestButton.setContentHuggingPriority(.required - 1, for: .horizontal) - userView.acceptFollowRequestButton.setContentCompressionResistancePriority(.required - 1, for: .horizontal) - userView.rejectFollowRequestButton.setContentHuggingPriority(.required - 1, for: .horizontal) - userView.rejectFollowRequestButton.setContentCompressionResistancePriority(.required - 1, for: .horizontal) - - userView.accessoryContainerView.addArrangedSubview(userView.activityIndicatorView) - userView.activityIndicatorView.setContentHuggingPriority(.required - 1, for: .horizontal) - userView.activityIndicatorView.setContentCompressionResistancePriority(.required - 1, for: .horizontal) - userView.activityIndicatorView.isHidden = true - - userView.setNeedsLayout() + var checkmarkView: some View { + Button { + // do nothing + } label: { + let name = viewModel.isSelect ? "checkmark.circle.fill" : "circle" + Image(systemName: name) + } + .buttonStyle(.borderless) + .disabled(!viewModel.isSelectable) } +} - func layoutMentionPick(userView: UserView) { - let headlineStackView = UIStackView() - userView.infoContainerStackView.addArrangedSubview(headlineStackView) - headlineStackView.axis = .horizontal - headlineStackView.spacing = 6 - headlineStackView.addArrangedSubview(userView.nameLabel) - userView.lockImageView.translatesAutoresizingMaskIntoConstraints = false - headlineStackView.addArrangedSubview(userView.lockImageView) - NSLayoutConstraint.activate([ - userView.lockImageView.heightAnchor.constraint(equalTo: userView.nameLabel.heightAnchor).priority(.required - 10), - ]) - userView.lockImageView.setContentHuggingPriority(.defaultLow, for: .horizontal) - userView.lockImageView.setContentCompressionResistancePriority(.required - 10, for: .horizontal) - headlineStackView.addArrangedSubview(UIView()) // padding - - - userView.infoContainerStackView.addArrangedSubview(userView.usernameLabel) - - userView.accessoryContainerView.addArrangedSubview(userView.checkmarkButton) - userView.checkmarkButton.setContentHuggingPriority(.required - 1, for: .horizontal) - userView.checkmarkButton.setContentCompressionResistancePriority(.required - 1, for: .horizontal) - - userView.accessoryContainerView.addArrangedSubview(userView.activityIndicatorView) - userView.activityIndicatorView.setContentHuggingPriority(.required - 1, for: .horizontal) - userView.activityIndicatorView.setContentCompressionResistancePriority(.required - 1, for: .horizontal) - userView.activityIndicatorView.isHidden = true - - userView.setNeedsLayout() +extension UserView { + var headlineView: some View { + Group { + switch viewModel.kind { + default: + HStack(spacing: 6) { + nameLabel + if viewModel.protected { + Image(uiImage: Asset.ObjectTools.lockMini.image.withRenderingMode(.alwaysTemplate)) + .resizable() + .frame(width: lockImageDimension, height: lockImageDimension) + .foregroundColor(.secondary) + Spacer() + } + } + } + } // end Group } - func layoutAddListMember(userView: UserView) { - layoutRelationshipBase(userView: userView) - - userView.membershipButton.translatesAutoresizingMaskIntoConstraints = false - userView.accessoryContainerView.addArrangedSubview(userView.membershipButton) - NSLayoutConstraint.activate([ - userView.membershipButton.widthAnchor.constraint(equalToConstant: 44), - userView.membershipButton.heightAnchor.constraint(equalToConstant: 44).priority(.required - 1), - ]) - userView.membershipButton.setContentHuggingPriority(.required - 1, for: .horizontal) - userView.membershipButton.setContentCompressionResistancePriority(.required - 1, for: .horizontal) - - userView.activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false - userView.accessoryContainerView.addSubview(userView.activityIndicatorView) - NSLayoutConstraint.activate([ - userView.activityIndicatorView.centerXAnchor.constraint(equalTo: userView.membershipButton.centerXAnchor), - userView.activityIndicatorView.centerYAnchor.constraint(equalTo: userView.membershipButton.centerYAnchor), - ]) - userView.activityIndicatorView.isHidden = true + var subheadlineView: some View { + Group { + switch viewModel.kind { + case .account: + usernameLabel + case .search: + usernameLabel + case .friend: + usernameLabel + case .history: + usernameLabel + case .notification: + usernameLabel + case .mentionPick: + usernameLabel + case .listMember: + usernameLabel + case .addListMember: + usernameLabel + case .settingAccountSection: + usernameLabel + case .plain: + usernameLabel + } + } // end Group } - func layoutListMember(userView: UserView) { - layoutRelationshipBase(userView: userView) - - userView.menuButton.translatesAutoresizingMaskIntoConstraints = false - userView.accessoryContainerView.addArrangedSubview(userView.menuButton) - userView.menuButton.setContentHuggingPriority(.required - 1, for: .horizontal) - userView.menuButton.setContentCompressionResistancePriority(.required - 1, for: .horizontal) + var accessoryView: some View { + Group { + switch viewModel.kind { + case .account: + HStack { + if viewModel.notificationBadgeCount > 0 { + notificationBadgeCountView + } + menuView + } + case .search: + // TODO: follow button + EmptyView() + case .friend: + EmptyView() + case .history: + EmptyView() + case .notification: + if viewModel.isFollowRequestActionDisplay { + followRequestActionView + } + case .mentionPick: + checkmarkView + case .listMember: + if viewModel.isMyList { + menuView + } + case .addListMember: + membershipButton + case .settingAccountSection: + Image(systemName: "chevron.right") + .foregroundColor(Color(.secondaryLabel)) + case .plain: + EmptyView() + } + } // end Group } - } -extension UserView { - public func setHeaderDisplay() { - headerContainerView.isHidden = false - } - - public func setFollowRequestControlDisplay() { - followRequestControlContainerView.isHidden = false - } -} +//public final class UserView: UIView { +// +// let logger = Logger(subsystem: "UserView", category: "UI") +// +// public static let avatarImageViewSize = CGSize(width: 44, height: 44) +// +// private var _disposeBag = Set() // which lifetime same to view scope +// var disposeBag = Set() // clear when reuse +// +// weak var delegate: UserViewDelegate? +// +// private(set) var style: Style? +// +// public private(set) lazy var viewModel: ViewModel = { +// let viewModel = ViewModel() +// viewModel.bind(userView: self) +// return viewModel +// }() +// +// // container +// public let containerStackView: UIStackView = { +// let stackView = UIStackView() +// stackView.axis = .vertical +// stackView.spacing = 8 +// return stackView +// }() +// +// public static var contentStackViewSpacing: CGFloat = 10 +// public let contentStackView: UIStackView = { +// let stackView = UIStackView() +// stackView.axis = .horizontal +// stackView.spacing = UserView.contentStackViewSpacing +// stackView.alignment = .center +// return stackView +// }() +// +// public let infoContainerStackView: UIStackView = { +// let stackView = UIStackView() +// stackView.axis = .vertical +// stackView.distribution = .fillEqually +// return stackView +// }() +// +// public let accessoryContainerView: UIStackView = { +// let stackView = UIStackView() +// stackView.axis = .horizontal +// stackView.alignment = .firstBaseline +// stackView.spacing = 8 +// return stackView +// }() +// +// // header +// public let headerContainerView = UIView() +// public let headerIconImageView = UIImageView() +// public static var headerTextLabelStyle: TextStyle { .statusHeader } +// public let headerTextLabel = MetaLabel(style: .statusHeader) +// +// // avatar +// public let authorProfileAvatarView: ProfileAvatarView = { +// let profileAvatarView = ProfileAvatarView() +// profileAvatarView.setup(dimension: .inline) +// return profileAvatarView +// }() +// +// // name +// public let nameLabel = MetaLabel(style: .userAuthorName) +// +// // username +// public let usernameLabel = PlainLabel(style: .userAuthorUsername) +// +// // lock +// public let lockImageView: UIImageView = { +// let imageView = UIImageView() +// imageView.tintColor = .secondaryLabel +// imageView.contentMode = .scaleAspectFill +// imageView.image = Asset.ObjectTools.lockMiniInline.image.withRenderingMode(.alwaysTemplate) +// return imageView +// }() +// +// // followerCount +// public let followerCountLabel = PlainLabel(style: .userDescription) +// +// // friendship control +// public let friendshipButton: FriendshipButton = { +// let button = FriendshipButton() +// button.titleFont = UIFontMetrics(forTextStyle: .headline) +// .scaledFont(for: UIFont.systemFont(ofSize: 13, weight: .semibold)) +// return button +// }() +// +// // checkmark control +// public let checkmarkButton: UIButton = { +// let button = UIButton() +// button.setImage(UIImage(systemName: "checkmark.circle.fill"), for: .normal) +// button.tintColor = Asset.Colors.hightLight.color +// return button +// }() +// +// // menu control +// public let menuButton: HitTestExpandedButton = { +// let button = HitTestExpandedButton() +// button.setImage(UIImage(systemName: "ellipsis.circle"), for: .normal) +// button.tintColor = Asset.Colors.hightLight.color +// return button +// }() +// +// // follow request +// public let followRequestControlContainerView = UIStackView() +// +// public private(set) lazy var acceptFollowRequestButton: HitTestExpandedButton = { +// let button = HitTestExpandedButton() +// button.setImage(Asset.Indices.checkmarkCircle.image.withRenderingMode(.alwaysTemplate), for: .normal) +// button.tintColor = Asset.Colors.hightLight.color // FIXME: tint color +// button.addTarget(self, action: #selector(UserView.acceptFollowRequestButtonDidPressed(_:)), for: .touchUpInside) +// button.accessibilityLabel = L10n.Common.Notification.FollowRequestAction.approve +// return button +// }() +// +// public private(set) lazy var rejectFollowRequestButton: HitTestExpandedButton = { +// let button = HitTestExpandedButton() +// button.setImage(Asset.Indices.xmarkCircle.image.withRenderingMode(.alwaysTemplate), for: .normal) +// button.tintColor = .secondaryLabel +// button.addTarget(self, action: #selector(UserView.rejectFollowRequestButtonDidPressed(_:)), for: .touchUpInside) +// button.accessibilityLabel = L10n.Common.Notification.FollowRequestAction.deny +// return button +// }() +// +// // activity indicator +// public let activityIndicatorView: UIActivityIndicatorView = { +// let activityIndicatorView = UIActivityIndicatorView(style: .medium) +// activityIndicatorView.hidesWhenStopped = true +// activityIndicatorView.startAnimating() +// return activityIndicatorView +// }() +// +// // badge +// public let badgeImageView: UIImageView = { +// let imageView = UIImageView() +// imageView.contentMode = .scaleAspectFit +// imageView.tintColor = .label +// return imageView +// }() +// +// public func prepareForReuse() { +// disposeBag.removeAll() +// viewModel.prepareForReuse() +// authorProfileAvatarView.avatarButton.avatarImageView.cancelTask() +// Style.prepareForReuse(userView: self) +// } +// +// public override init(frame: CGRect) { +// super.init(frame: frame) +// _init() +// } +// +// public required init?(coder: NSCoder) { +// super.init(coder: coder) +// _init() +// } +// +//} +// +//extension UserView { +// public enum MenuAction: Hashable { +// case signOut +// case remove +// } +// +//} + +//extension UserView { +// private func _init() { +// containerStackView.translatesAutoresizingMaskIntoConstraints = false +// addSubview(containerStackView) +// NSLayoutConstraint.activate([ +// containerStackView.topAnchor.constraint(equalTo: topAnchor), +// containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor), +// trailingAnchor.constraint(equalTo: containerStackView.trailingAnchor), +// bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor), +// ]) +// +// // container:: V - [ header container | content ] +// containerStackView.addArrangedSubview(headerContainerView) +// containerStackView.addArrangedSubview(contentStackView) +// +// // content: H - [ user avatar | info container | accessory container ] +// authorProfileAvatarView.translatesAutoresizingMaskIntoConstraints = false +// contentStackView.addArrangedSubview(authorProfileAvatarView) +// NSLayoutConstraint.activate([ +// authorProfileAvatarView.widthAnchor.constraint(equalToConstant: UserView.avatarImageViewSize.width).priority(.required - 1), +// authorProfileAvatarView.heightAnchor.constraint(equalToConstant: UserView.avatarImageViewSize.height).priority(.required - 1), +// ]) +// contentStackView.addArrangedSubview(infoContainerStackView) +// contentStackView.addArrangedSubview(accessoryContainerView) +// +// authorProfileAvatarView.isUserInteractionEnabled = false +// nameLabel.isUserInteractionEnabled = false +// usernameLabel.isUserInteractionEnabled = false +// followerCountLabel.isUserInteractionEnabled = false +// +// membershipButton.addTarget(self, action: #selector(UserView.membershipButtonDidPressed(_:)), for: .touchUpInside) +// +// #if DEBUG +// nameLabel.configure(content: PlaintextMetaContent(string: "Name")) +// usernameLabel.text = "@username" +// followerCountLabel.text = "1000 Followers" +// #endif +// } +// +// public func setup(style: Style) { +// guard self.style == nil else { +// assertionFailure("Should only setup once") +// return +// } +// self.style = style +// style.layout(userView: self) +// Style.prepareForReuse(userView: self) +// } +//} +// +//extension UserView { +// +// @objc private func membershipButtonDidPressed(_ sender: UIButton) { +// logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") +// delegate?.userView(self, membershipButtonDidPressed: sender) +// } +// +// @objc private func acceptFollowRequestButtonDidPressed(_ sender: UIButton) { +// logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") +// delegate?.userView(self, acceptFollowReqeustButtonDidPressed: sender) +// } +// +// @objc private func rejectFollowRequestButtonDidPressed(_ sender: UIButton) { +// logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") +// delegate?.userView(self, rejectFollowReqeustButtonDidPressed: sender) +// } +// +//} +// +//extension UserView { +// public enum Style { +// // headline: name | lock +// // subheadline: username +// // accessory: [ badge | menu ] +// case account +// +// // headline: name | lock | username +// // subheadline: follower count +// // accessory: follow button +// case relationship +// +// // headline: name | lock +// // subheadline: username +// // accessory: action button +// case friendship +// +// // header: notification +// // headline: name | lock | username +// // subheadline: follower count +// // accessory: [ followRquest accept and reject button ] +// case notification +// +// // headline: name | lock +// // subheadline: username +// // accessory: checkmark button +// case mentionPick +// +// // headline: name | lock | username +// // subheadline: follower count +// // accessory: membership menu +// case listMember +// +// // headline: name | lock | username +// // subheadline: follower count +// // accessory: membership button +// case addListMember +// +// public func layout(userView: UserView) { +// switch self { +// case .account: layoutAccount(userView: userView) +// case .relationship: layoutRelationship(userView: userView) +// case .friendship: layoutFriendship(userView: userView) +// case .notification: layoutNotification(userView: userView) +// case .mentionPick: layoutMentionPick(userView: userView) +// case .listMember: layoutListMember(userView: userView) +// case .addListMember: layoutAddListMember(userView: userView) +// } +// } +// +// public static func prepareForReuse(userView: UserView) { +// userView.headerContainerView.isHidden = true +// userView.followRequestControlContainerView.isHidden = true +// } +// } +//} +// +//extension UserView.Style { +// +// // headline: name | lock | username +// // subheadline: follower count +// private func layoutRelationshipBase(userView: UserView) { +// let headlineStackView = UIStackView() +// userView.infoContainerStackView.addArrangedSubview(headlineStackView) +// headlineStackView.axis = .horizontal +// headlineStackView.spacing = 6 +// headlineStackView.addArrangedSubview(userView.nameLabel) +// userView.lockImageView.translatesAutoresizingMaskIntoConstraints = false +// headlineStackView.addArrangedSubview(userView.lockImageView) +// NSLayoutConstraint.activate([ +// userView.lockImageView.heightAnchor.constraint(equalTo: userView.nameLabel.heightAnchor).priority(.required - 10), +// ]) +// userView.lockImageView.setContentHuggingPriority(.defaultLow, for: .horizontal) +// userView.lockImageView.setContentCompressionResistancePriority(.required - 10, for: .horizontal) +// headlineStackView.addArrangedSubview(UIView()) // padding +// +// userView.infoContainerStackView.addArrangedSubview(userView.usernameLabel) +// } +// +// // FIXME: update layout +// func layoutAccount(userView: UserView) { +// let headlineStackView = UIStackView() +// userView.infoContainerStackView.addArrangedSubview(headlineStackView) +// headlineStackView.axis = .horizontal +// headlineStackView.spacing = 6 +// headlineStackView.addArrangedSubview(userView.nameLabel) +// userView.lockImageView.translatesAutoresizingMaskIntoConstraints = false +// headlineStackView.addArrangedSubview(userView.lockImageView) +// NSLayoutConstraint.activate([ +// userView.lockImageView.heightAnchor.constraint(equalTo: userView.nameLabel.heightAnchor).priority(.required - 10), +// ]) +// userView.lockImageView.setContentHuggingPriority(.defaultLow, for: .horizontal) +// userView.lockImageView.setContentCompressionResistancePriority(.required - 10, for: .horizontal) +// headlineStackView.addArrangedSubview(UIView()) // padding +// +// userView.infoContainerStackView.addArrangedSubview(userView.usernameLabel) +// +// userView.accessoryContainerView.addArrangedSubview(userView.badgeImageView) +// userView.accessoryContainerView.addArrangedSubview(userView.menuButton) +// userView.badgeImageView.setContentHuggingPriority(.required - 2, for: .horizontal) +// userView.badgeImageView.setContentCompressionResistancePriority(.required - 1, for: .vertical) +// userView.menuButton.setContentHuggingPriority(.required - 1, for: .horizontal) +// +// userView.setNeedsLayout() +// } +// +// // FIXME: update layout +// func layoutRelationship(userView: UserView) { +// layoutRelationshipBase(userView: userView) +// +// userView.friendshipButton.translatesAutoresizingMaskIntoConstraints = false +// userView.accessoryContainerView.addArrangedSubview(userView.friendshipButton) +// NSLayoutConstraint.activate([ +//// userView.friendshipButton.heightAnchor.constraint(equalToConstant: 34).priority(.required - 1), +// userView.friendshipButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 80).priority(.required - 1), +// ]) +// userView.friendshipButton.setContentHuggingPriority(.required - 10, for: .horizontal) +// userView.friendshipButton.setContentCompressionResistancePriority(.required - 10, for: .horizontal) +// +// userView.setNeedsLayout() +// } +// +// func layoutFriendship(userView: UserView) { +// // headline +// let headlineStackView = UIStackView() +// userView.infoContainerStackView.addArrangedSubview(headlineStackView) +// headlineStackView.axis = .horizontal +// headlineStackView.spacing = 6 +// headlineStackView.addArrangedSubview(userView.nameLabel) +// userView.lockImageView.translatesAutoresizingMaskIntoConstraints = false +// headlineStackView.addArrangedSubview(userView.lockImageView) +// NSLayoutConstraint.activate([ +// userView.lockImageView.heightAnchor.constraint(equalTo: userView.nameLabel.heightAnchor).priority(.required - 10), +// ]) +// userView.lockImageView.setContentHuggingPriority(.defaultLow, for: .horizontal) +// userView.lockImageView.setContentCompressionResistancePriority(.required - 10, for: .horizontal) +// headlineStackView.addArrangedSubview(userView.usernameLabel) +// headlineStackView.addArrangedSubview(UIView()) // padding +// +// // subheadline +// userView.infoContainerStackView.addArrangedSubview(userView.followerCountLabel) +// +// // TODO: menu +// +// userView.setNeedsLayout() +// } +// +// func layoutNotification(userView: UserView) { +// userView.headerIconImageView.translatesAutoresizingMaskIntoConstraints = false +// userView.headerTextLabel.translatesAutoresizingMaskIntoConstraints = false +// userView.headerContainerView.addSubview(userView.headerIconImageView) +// userView.headerContainerView.addSubview(userView.headerTextLabel) +// NSLayoutConstraint.activate([ +// userView.headerTextLabel.topAnchor.constraint(equalTo: userView.headerContainerView.topAnchor), +// userView.headerTextLabel.bottomAnchor.constraint(equalTo: userView.headerContainerView.bottomAnchor), +// userView.headerTextLabel.trailingAnchor.constraint(equalTo: userView.headerContainerView.trailingAnchor), +// userView.headerIconImageView.centerYAnchor.constraint(equalTo: userView.headerTextLabel.centerYAnchor), +// userView.headerIconImageView.heightAnchor.constraint(equalTo: userView.headerTextLabel.heightAnchor, multiplier: 1.0).priority(.required - 1), +// userView.headerIconImageView.widthAnchor.constraint(equalTo: userView.headerIconImageView.heightAnchor, multiplier: 1.0).priority(.required - 1), +// userView.headerTextLabel.leadingAnchor.constraint(equalTo: userView.headerIconImageView.trailingAnchor, constant: 4), +// // align to author name below +// ]) +// userView.headerTextLabel.setContentHuggingPriority(.required - 10, for: .vertical) +// userView.headerIconImageView.setContentHuggingPriority(.defaultLow, for: .vertical) +// userView.headerIconImageView.setContentHuggingPriority(.defaultLow, for: .horizontal) +// +// layoutRelationshipBase(userView: userView) +// +// // set header label align to author name +// NSLayoutConstraint.activate([ +// userView.headerTextLabel.leadingAnchor.constraint(equalTo: userView.authorProfileAvatarView.trailingAnchor, constant: UserView.contentStackViewSpacing), +// ]) +// +// // follow request button +// userView.accessoryContainerView.addArrangedSubview(userView.followRequestControlContainerView) +// userView.followRequestControlContainerView.axis = .horizontal +// userView.followRequestControlContainerView.spacing = 20 +// userView.followRequestControlContainerView.isHidden = true +// +// userView.followRequestControlContainerView.addArrangedSubview(userView.acceptFollowRequestButton) +// userView.followRequestControlContainerView.addArrangedSubview(userView.rejectFollowRequestButton) +// userView.acceptFollowRequestButton.setContentHuggingPriority(.required - 1, for: .horizontal) +// userView.acceptFollowRequestButton.setContentCompressionResistancePriority(.required - 1, for: .horizontal) +// userView.rejectFollowRequestButton.setContentHuggingPriority(.required - 1, for: .horizontal) +// userView.rejectFollowRequestButton.setContentCompressionResistancePriority(.required - 1, for: .horizontal) +// +// userView.accessoryContainerView.addArrangedSubview(userView.activityIndicatorView) +// userView.activityIndicatorView.setContentHuggingPriority(.required - 1, for: .horizontal) +// userView.activityIndicatorView.setContentCompressionResistancePriority(.required - 1, for: .horizontal) +// userView.activityIndicatorView.isHidden = true +// +// userView.setNeedsLayout() +// } +// +// func layoutMentionPick(userView: UserView) { +// let headlineStackView = UIStackView() +// userView.infoContainerStackView.addArrangedSubview(headlineStackView) +// headlineStackView.axis = .horizontal +// headlineStackView.spacing = 6 +// headlineStackView.addArrangedSubview(userView.nameLabel) +// userView.lockImageView.translatesAutoresizingMaskIntoConstraints = false +// headlineStackView.addArrangedSubview(userView.lockImageView) +// NSLayoutConstraint.activate([ +// userView.lockImageView.heightAnchor.constraint(equalTo: userView.nameLabel.heightAnchor).priority(.required - 10), +// ]) +// userView.lockImageView.setContentHuggingPriority(.defaultLow, for: .horizontal) +// userView.lockImageView.setContentCompressionResistancePriority(.required - 10, for: .horizontal) +// headlineStackView.addArrangedSubview(UIView()) // padding +// +// +// userView.infoContainerStackView.addArrangedSubview(userView.usernameLabel) +// +// userView.accessoryContainerView.addArrangedSubview(userView.checkmarkButton) +// userView.checkmarkButton.setContentHuggingPriority(.required - 1, for: .horizontal) +// userView.checkmarkButton.setContentCompressionResistancePriority(.required - 1, for: .horizontal) +// +// userView.accessoryContainerView.addArrangedSubview(userView.activityIndicatorView) +// userView.activityIndicatorView.setContentHuggingPriority(.required - 1, for: .horizontal) +// userView.activityIndicatorView.setContentCompressionResistancePriority(.required - 1, for: .horizontal) +// userView.activityIndicatorView.isHidden = true +// +// userView.setNeedsLayout() +// } +// +// func layoutAddListMember(userView: UserView) { +// layoutRelationshipBase(userView: userView) +// +// userView.membershipButton.translatesAutoresizingMaskIntoConstraints = false +// userView.accessoryContainerView.addArrangedSubview(userView.membershipButton) +// NSLayoutConstraint.activate([ +// userView.membershipButton.widthAnchor.constraint(equalToConstant: 44), +// userView.membershipButton.heightAnchor.constraint(equalToConstant: 44).priority(.required - 1), +// ]) +// userView.membershipButton.setContentHuggingPriority(.required - 1, for: .horizontal) +// userView.membershipButton.setContentCompressionResistancePriority(.required - 1, for: .horizontal) +// +// userView.activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false +// userView.accessoryContainerView.addSubview(userView.activityIndicatorView) +// NSLayoutConstraint.activate([ +// userView.activityIndicatorView.centerXAnchor.constraint(equalTo: userView.membershipButton.centerXAnchor), +// userView.activityIndicatorView.centerYAnchor.constraint(equalTo: userView.membershipButton.centerYAnchor), +// ]) +// userView.activityIndicatorView.isHidden = true +// } +// +// func layoutListMember(userView: UserView) { +// layoutRelationshipBase(userView: userView) +// +// userView.menuButton.translatesAutoresizingMaskIntoConstraints = false +// userView.accessoryContainerView.addArrangedSubview(userView.menuButton) +// userView.menuButton.setContentHuggingPriority(.required - 1, for: .horizontal) +// userView.menuButton.setContentCompressionResistancePriority(.required - 1, for: .horizontal) +// } +// +//} +// +//extension UserView { +// public func setHeaderDisplay() { +// headerContainerView.isHidden = false +// } +// +// public func setFollowRequestControlDisplay() { +// followRequestControlContainerView.isHidden = false +// } +//} #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 { - 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 + List { + ForEach(kinds, id: \.self) { kind in + Section(content: { + UserView(viewModel: .init(kind: kind)) + .padding(.horizontal) + }, header: { + Text("\(String(describing: kind).localizedCapitalized)") + }) + .textCase(nil) } - .previewLayout(.fixed(width: 375, height: 48)) - .previewDisplayName("AddListMember") } } } diff --git a/TwidereSDK/Sources/TwidereUI/ContextMenu/TimelineTableViewCellContextMenuConfiguration.swift b/TwidereSDK/Sources/TwidereUI/ContextMenu/TimelineTableViewCellContextMenuConfiguration.swift index f4a75d58..3d4fd858 100644 --- a/TwidereSDK/Sources/TwidereUI/ContextMenu/TimelineTableViewCellContextMenuConfiguration.swift +++ b/TwidereSDK/Sources/TwidereUI/ContextMenu/TimelineTableViewCellContextMenuConfiguration.swift @@ -13,5 +13,6 @@ public final class TimelineTableViewCellContextMenuConfiguration: UIContextMenuC public var indexPath: IndexPath? public var index: Int? + public var mediaViewModel: MediaView.ViewModel? } diff --git a/TwidereSDK/Sources/TwidereUI/Control/ContentWarningOverlayView.swift b/TwidereSDK/Sources/TwidereUI/Control/ContentWarningOverlayView.swift index 4a695f81..0f1533eb 100644 --- a/TwidereSDK/Sources/TwidereUI/Control/ContentWarningOverlayView.swift +++ b/TwidereSDK/Sources/TwidereUI/Control/ContentWarningOverlayView.swift @@ -7,78 +7,147 @@ import os.log import UIKit +import SwiftUI import TwidereAsset -public protocol ContentWarningOverlayViewDelegate: AnyObject { - func contentWarningOverlayViewDidPressed(_ contentWarningOverlayView: ContentWarningOverlayView) -} - -public final class ContentWarningOverlayView: UIView { - - public static let blurVisualEffect = UIBlurEffect(style: .systemUltraThinMaterial) - - let logger = Logger(subsystem: "ContentWarningOverlayView", category: "View") +public struct ContentWarningOverlayView: View { - public weak var delegate: ContentWarningOverlayViewDelegate? + let isReveal: Bool + let onTapGesture: () -> Void - public let blurVisualEffectView = UIVisualEffectView(effect: ContentWarningOverlayView.blurVisualEffect) - public let vibrancyVisualEffectView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: ContentWarningOverlayView.blurVisualEffect)) - let alertImageView: UIImageView = { - let imageView = UIImageView() - imageView.image = Asset.Indices.exclamationmarkTriangleLarge.image.withRenderingMode(.alwaysTemplate) - return imageView - }() - - public let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + public var body: some View { + Color.clear + .overlay { + if !isReveal { + Image(uiImage: Asset.Indices.exclamationmarkTriangleLarge.image.withRenderingMode(.alwaysTemplate)) + .foregroundColor(.secondary) + } + } + .background(.thinMaterial) + .opacity(isReveal ? 0 : 1) + .animation(.easeInOut(duration: 0.2), value: isReveal) + .onTapGesture { + onTapGesture() + } + .overlay(alignment: .top) { + HStack { + if isReveal { + Button { + onTapGesture() + } label: { + Image(uiImage: Asset.Human.eyeSlashMini.image.withRenderingMode(.alwaysTemplate)) + .foregroundColor(.secondary) + .padding(4) + .background(.regularMaterial) + .cornerRadius(6) + .padding(8) + } + } + Spacer() + } + } + } +} + +struct ContentWarningOverlayView_Previews: PreviewProvider { - override init(frame: CGRect) { - super.init(frame: frame) - _init() + static var viewModel: MediaView.ViewModel { + MediaView.ViewModel( + mediaKind: .photo, + aspectRatio: CGSize(width: 2048, height: 1186), + altText: nil, + previewURL: URL(string: "https://www.nasa.gov/sites/default/files/thumbnails/image/web_first_images_release.png"), + assetURL: URL(string: "https://www.nasa.gov/sites/default/files/thumbnails/image/web_first_images_release.png"), + downloadURL: URL(string: "https://www.nasa.gov/sites/default/files/thumbnails/image/web_first_images_release.png"), + durationMS: nil + ) } - required init?(coder: NSCoder) { - super.init(coder: coder) - _init() + class StateViewModel { + var isReveal = false } -} - -extension ContentWarningOverlayView { - private func _init() { - // overlay - blurVisualEffectView.translatesAutoresizingMaskIntoConstraints = false - addSubview(blurVisualEffectView) - NSLayoutConstraint.activate([ - blurVisualEffectView.topAnchor.constraint(equalTo: topAnchor), - blurVisualEffectView.leadingAnchor.constraint(equalTo: leadingAnchor), - blurVisualEffectView.trailingAnchor.constraint(equalTo: trailingAnchor), - blurVisualEffectView.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - - vibrancyVisualEffectView.translatesAutoresizingMaskIntoConstraints = false - blurVisualEffectView.contentView.addSubview(vibrancyVisualEffectView) - NSLayoutConstraint.activate([ - vibrancyVisualEffectView.topAnchor.constraint(equalTo: blurVisualEffectView.contentView.topAnchor), - vibrancyVisualEffectView.leadingAnchor.constraint(equalTo: blurVisualEffectView.contentView.leadingAnchor), - vibrancyVisualEffectView.trailingAnchor.constraint(equalTo: blurVisualEffectView.contentView.trailingAnchor), - vibrancyVisualEffectView.bottomAnchor.constraint(equalTo: blurVisualEffectView.contentView.bottomAnchor), - ]) - - alertImageView.translatesAutoresizingMaskIntoConstraints = false - vibrancyVisualEffectView.contentView.addSubview(alertImageView) - NSLayoutConstraint.activate([ - alertImageView.centerXAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.centerXAnchor), - alertImageView.centerYAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.centerYAnchor), - ]) - - tapGestureRecognizer.addTarget(self, action: #selector(ContentWarningOverlayView.tapGestureRecognizerHandler(_:))) - addGestureRecognizer(tapGestureRecognizer) + static let contentWarningOverlayViewModel = StateViewModel() + + static var previews: some View { + MediaView(viewModel: viewModel) + .frame(width: 300, height: 200) + .previewLayout(.fixed(width: 300, height: 200)) + .overlay { + ContentWarningOverlayView(isReveal: contentWarningOverlayViewModel.isReveal) { + contentWarningOverlayViewModel.isReveal.toggle() + } + } } } -extension ContentWarningOverlayView { - @objc private func tapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") - delegate?.contentWarningOverlayViewDidPressed(self) - } -} + +//public final class ContentWarningOverlayView: UIView { +// +// public static let blurVisualEffect = UIBlurEffect(style: .systemUltraThinMaterial) +// +// let logger = Logger(subsystem: "ContentWarningOverlayView", category: "View") +// +// public weak var delegate: ContentWarningOverlayViewDelegate? +// +// public let blurVisualEffectView = UIVisualEffectView(effect: ContentWarningOverlayView.blurVisualEffect) +// public let vibrancyVisualEffectView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: ContentWarningOverlayView.blurVisualEffect)) +// let alertImageView: UIImageView = { +// let imageView = UIImageView() +// imageView.image = Asset.Indices.exclamationmarkTriangleLarge.image.withRenderingMode(.alwaysTemplate) +// return imageView +// }() +// +// public let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer +// +// override init(frame: CGRect) { +// super.init(frame: frame) +// _init() +// } +// +// required init?(coder: NSCoder) { +// super.init(coder: coder) +// _init() +// } +// +//} +// +//extension ContentWarningOverlayView { +// private func _init() { +// // overlay +// blurVisualEffectView.translatesAutoresizingMaskIntoConstraints = false +// addSubview(blurVisualEffectView) +// NSLayoutConstraint.activate([ +// blurVisualEffectView.topAnchor.constraint(equalTo: topAnchor), +// blurVisualEffectView.leadingAnchor.constraint(equalTo: leadingAnchor), +// blurVisualEffectView.trailingAnchor.constraint(equalTo: trailingAnchor), +// blurVisualEffectView.bottomAnchor.constraint(equalTo: bottomAnchor), +// ]) +// +// vibrancyVisualEffectView.translatesAutoresizingMaskIntoConstraints = false +// blurVisualEffectView.contentView.addSubview(vibrancyVisualEffectView) +// NSLayoutConstraint.activate([ +// vibrancyVisualEffectView.topAnchor.constraint(equalTo: blurVisualEffectView.contentView.topAnchor), +// vibrancyVisualEffectView.leadingAnchor.constraint(equalTo: blurVisualEffectView.contentView.leadingAnchor), +// vibrancyVisualEffectView.trailingAnchor.constraint(equalTo: blurVisualEffectView.contentView.trailingAnchor), +// vibrancyVisualEffectView.bottomAnchor.constraint(equalTo: blurVisualEffectView.contentView.bottomAnchor), +// ]) +// +// alertImageView.translatesAutoresizingMaskIntoConstraints = false +// vibrancyVisualEffectView.contentView.addSubview(alertImageView) +// NSLayoutConstraint.activate([ +// alertImageView.centerXAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.centerXAnchor), +// alertImageView.centerYAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.centerYAnchor), +// ]) +// +// tapGestureRecognizer.addTarget(self, action: #selector(ContentWarningOverlayView.tapGestureRecognizerHandler(_:))) +// addGestureRecognizer(tapGestureRecognizer) +// } +//} +// +//extension ContentWarningOverlayView { +// @objc private func tapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { +// logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") +// delegate?.contentWarningOverlayViewDidPressed(self) +// } +//} diff --git a/TwidereSDK/Sources/TwidereUI/Control/ReplySettingBannerView.swift b/TwidereSDK/Sources/TwidereUI/Control/ReplySettingBannerView.swift index f8acb018..b8b347ee 100644 --- a/TwidereSDK/Sources/TwidereUI/Control/ReplySettingBannerView.swift +++ b/TwidereSDK/Sources/TwidereUI/Control/ReplySettingBannerView.swift @@ -6,76 +6,96 @@ // import UIKit +import SwiftUI +import TwitterSDK import TwidereAsset +import TwidereLocalization -final public class ReplySettingBannerView: UIView { +public struct ReplySettingBannerView: View { - let topSeparator = SeparatorLineView() - - let overflowBackgroundView = UIView() - - let stackView = UIStackView() - - public let imageView = UIImageView() - - public let label: UILabel = { - let label = UILabel() - label.font = .preferredFont(forTextStyle: .callout) - label.numberOfLines = 0 - return label - }() - - public override init(frame: CGRect) { - super.init(frame: frame) - _init() - } + public let viewModel: ViewModel + + @ScaledMetric(relativeTo: .callout) private var imageDimension: CGFloat = 16 + - public required init?(coder: NSCoder) { - super.init(coder: coder) - _init() + public var body: some View { + HStack(spacing: 4) { + Image(uiImage: viewModel.icon) + .resizable() + .frame(width: imageDimension, height: imageDimension) + Text(viewModel.title) + } + .font(.callout) + .foregroundColor(.white) + .padding(.vertical, 8) } } extension ReplySettingBannerView { - private func _init() { - // Hack the background view to fill the table width - overflowBackgroundView.translatesAutoresizingMaskIntoConstraints = false - addSubview(overflowBackgroundView) - NSLayoutConstraint.activate([ - overflowBackgroundView.topAnchor.constraint(equalTo: topAnchor), - leadingAnchor.constraint(equalTo: overflowBackgroundView.leadingAnchor, constant: 400), - overflowBackgroundView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 400), - overflowBackgroundView.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - - // Hack the line to fill the table width - topSeparator.translatesAutoresizingMaskIntoConstraints = false - addSubview(topSeparator) - NSLayoutConstraint.activate([ - topSeparator.topAnchor.constraint(equalTo: topSeparator.topAnchor), - leadingAnchor.constraint(equalTo: topSeparator.leadingAnchor, constant: 400), - topSeparator.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 400), - ]) + public class ViewModel: ObservableObject { + // input + public let replaySettings: Twitter.Entity.V2.Tweet.ReplySettings + public let authorUsername: String - // stackView: H - [ icon | label ] - stackView.axis = .horizontal - stackView.translatesAutoresizingMaskIntoConstraints = false - stackView.alignment = .center - stackView.spacing = 4 - addSubview(stackView) - NSLayoutConstraint.activate([ - stackView.topAnchor.constraint(equalTo: topAnchor, constant: 8), - stackView.leadingAnchor.constraint(equalTo: leadingAnchor), - stackView.trailingAnchor.constraint(equalTo: trailingAnchor), - bottomAnchor.constraint(equalTo: stackView.bottomAnchor, constant: 8), - ]) + // output + public let icon: UIImage + public let title: String + public let shouldHidden: Bool - stackView.addArrangedSubview(imageView) - stackView.addArrangedSubview(label) - - overflowBackgroundView.backgroundColor = Asset.Colors.hightLight.color.withAlphaComponent(0.6) - imageView.tintColor = .white - label.textColor = .white + public init( + replaySettings: Twitter.Entity.V2.Tweet.ReplySettings, + authorUsername: String + ) { + self.replaySettings = replaySettings + self.authorUsername = authorUsername + self.icon = { + switch replaySettings { + case .everyone: + fallthrough + case .following: + return Asset.Communication.at.image.withRenderingMode(.alwaysTemplate) + case .mentionedUsers: + return Asset.Human.personCheckMini.image.withRenderingMode(.alwaysTemplate) + } + }() + self.title = { + switch replaySettings { + case .everyone: + return "" + case .following: + return L10n.Common.Controls.Status.ReplySettings.peopleUserFollowsOrMentionedCanReply("@\(authorUsername)") + case .mentionedUsers: + return L10n.Common.Controls.Status.ReplySettings.peopleUserMentionedCanReply("@\(authorUsername)") + } + }() + self.shouldHidden = replaySettings == .everyone + // end init + } } } + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +@available(iOS 13.0, *) +struct ReplySettingBannerView_Previews: PreviewProvider { + + static var previews: some View { + Group { + ReplySettingBannerView(viewModel: .init( + replaySettings: .following, + authorUsername: "alice" + )) + ReplySettingBannerView(viewModel: .init( + replaySettings: .mentionedUsers, + authorUsername: "alice" + )) + } + .background(Color.black) + } + +} + +#endif + diff --git a/TwidereSDK/Sources/TwidereUI/Control/UnreadIndicatorView.swift b/TwidereSDK/Sources/TwidereUI/Control/UnreadIndicatorView.swift index f13af2ac..9a8a892a 100644 --- a/TwidereSDK/Sources/TwidereUI/Control/UnreadIndicatorView.swift +++ b/TwidereSDK/Sources/TwidereUI/Control/UnreadIndicatorView.swift @@ -30,7 +30,9 @@ final public class UnreadIndicatorView: UIView { } } + // input public var count = 0 + private var currentCount = 0 var displayLink: CADisplayLink? diff --git a/TwidereSDK/Sources/TwidereUI/Extension/FLAnimatedImageView.swift b/TwidereSDK/Sources/TwidereUI/Diffable/Extension/FLAnimatedImageView.swift similarity index 100% rename from TwidereSDK/Sources/TwidereUI/Extension/FLAnimatedImageView.swift rename to TwidereSDK/Sources/TwidereUI/Diffable/Extension/FLAnimatedImageView.swift diff --git a/TwidereSDK/Sources/TwidereUI/Extension/PhotoLibraryService.swift b/TwidereSDK/Sources/TwidereUI/Diffable/Extension/PhotoLibraryService.swift similarity index 100% rename from TwidereSDK/Sources/TwidereUI/Extension/PhotoLibraryService.swift rename to TwidereSDK/Sources/TwidereUI/Diffable/Extension/PhotoLibraryService.swift diff --git a/TwidereSDK/Sources/TwidereUI/Extension/SwiftMessages.swift b/TwidereSDK/Sources/TwidereUI/Diffable/Extension/SwiftMessages.swift similarity index 100% rename from TwidereSDK/Sources/TwidereUI/Extension/SwiftMessages.swift rename to TwidereSDK/Sources/TwidereUI/Diffable/Extension/SwiftMessages.swift diff --git a/TwidereSDK/Sources/TwidereUI/Extension/UIAlertController.swift b/TwidereSDK/Sources/TwidereUI/Diffable/Extension/UIAlertController.swift similarity index 100% rename from TwidereSDK/Sources/TwidereUI/Extension/UIAlertController.swift rename to TwidereSDK/Sources/TwidereUI/Diffable/Extension/UIAlertController.swift diff --git a/TwidereSDK/Sources/TwidereUI/Extension/UIApplication.swift b/TwidereSDK/Sources/TwidereUI/Diffable/Extension/UIApplication.swift similarity index 100% rename from TwidereSDK/Sources/TwidereUI/Extension/UIApplication.swift rename to TwidereSDK/Sources/TwidereUI/Diffable/Extension/UIApplication.swift diff --git a/TwidereSDK/Sources/TwidereUI/Extension/UIImage.swift b/TwidereSDK/Sources/TwidereUI/Diffable/Extension/UIImage.swift similarity index 100% rename from TwidereSDK/Sources/TwidereUI/Extension/UIImage.swift rename to TwidereSDK/Sources/TwidereUI/Diffable/Extension/UIImage.swift diff --git a/TwidereSDK/Sources/TwidereUI/Extension/UITableView.swift b/TwidereSDK/Sources/TwidereUI/Diffable/Extension/UITableView.swift similarity index 97% rename from TwidereSDK/Sources/TwidereUI/Extension/UITableView.swift rename to TwidereSDK/Sources/TwidereUI/Diffable/Extension/UITableView.swift index 411692be..fd1a4136 100644 --- a/TwidereSDK/Sources/TwidereUI/Extension/UITableView.swift +++ b/TwidereSDK/Sources/TwidereUI/Diffable/Extension/UITableView.swift @@ -19,6 +19,7 @@ extension UITableView { extension UITableView { + // TODO: tweak cell selection background color public func deselectRow(with transitionCoordinator: UIViewControllerTransitionCoordinator?, animated: Bool) { guard let indexPathForSelectedRow = indexPathForSelectedRow else { return } diff --git a/TwidereSDK/Sources/TwidereUI/Extension/UIViewController.swift b/TwidereSDK/Sources/TwidereUI/Diffable/Extension/UIViewController.swift similarity index 100% rename from TwidereSDK/Sources/TwidereUI/Extension/UIViewController.swift rename to TwidereSDK/Sources/TwidereUI/Diffable/Extension/UIViewController.swift diff --git a/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/Attachment/AttachmentView.swift b/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/Attachment/AttachmentView.swift index 6d836d0f..9547ac5a 100644 --- a/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/Attachment/AttachmentView.swift +++ b/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/Attachment/AttachmentView.swift @@ -106,6 +106,7 @@ public struct AttachmentView: View { let canAddCaption: Bool = { switch viewModel.output { case .image: return true + case .gif: return false case .video: return false case .none: return false } @@ -150,6 +151,11 @@ public struct AttachmentView: View { Image(uiImage: image) .resizable() .aspectRatio(contentMode: .fill) + case .gif: + let image = viewModel.thumbnail ?? .placeholder(color: .systemGray3) + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fill) case .video(let url, _): let player = AVPlayer(url: url) VideoPlayer(player: player) @@ -209,6 +215,11 @@ public struct AttachmentView: View { Image(uiImage: image) .resizable() .aspectRatio(contentMode: .fill) + case .gif: + let image = viewModel.thumbnail ?? .placeholder(color: .systemGray3) + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fill) case .video(let url, _): let player = AVPlayer(url: url) VideoPlayer(player: player) diff --git a/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/Attachment/AttachmentViewModel+Upload.swift b/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/Attachment/AttachmentViewModel+Upload.swift index f760bd7d..b0ada45d 100644 --- a/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/Attachment/AttachmentViewModel+Upload.swift +++ b/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/Attachment/AttachmentViewModel+Upload.swift @@ -171,8 +171,11 @@ extension AttachmentViewModel { type: imageData.kf.imageFormat == .PNG ? UTType.png : UTType.jpeg ) -// case .gif(let url): -// fatalError() + case .gif(_, let url): + return SliceResult( + url: url, + type: .gif + ) case .video(let url, _): return SliceResult( url: url, @@ -251,12 +254,24 @@ extension AttachmentViewModel { chunkIndex += 1 } + var isFinalizing = false var isFinalized = false repeat { - let mediaFinalizedResponse = try await context.apiService.TwitterMediaFinalize( - mediaID: mediaID, - twitterAuthenticationContext: twitterAuthenticationContext - ) + let mediaFinalizedResponse: Twitter.Response.Content = try await { + if !isFinalizing { + let result = try await context.apiService.TwitterMediaFinalize( + mediaID: mediaID, + twitterAuthenticationContext: twitterAuthenticationContext + ) + isFinalizing = true + return result + } else { + return try await context.apiService.twitterMediaStatus( + mediaID: mediaID, + twitterAuthenticationContext: twitterAuthenticationContext + ) + } + }() guard let processingInfo = mediaFinalizedResponse.value.processingInfo else { isFinalized = true @@ -265,14 +280,14 @@ extension AttachmentViewModel { if let checkAfterSecs = processingInfo.checkAfterSecs { let checkAfterSeconds = UInt64(checkAfterSecs) - AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): finalize status pending. check after \(checkAfterSecs)s") + AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): mediaID: \(mediaID) - finalize status pending. check after \(checkAfterSecs)s") assert(!Thread.isMainThread) try? await Task.sleep(nanoseconds: checkAfterSeconds * .second) // 1s * checkAfterSeconds continue } else { - AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): finalize success") + AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): mediaID: \(mediaID) - finalize success") isFinalized = true } } while !isFinalized @@ -404,6 +419,8 @@ extension AttachmentViewModel.Output { case .png: return .png(data) case .jpg: return .jpeg(data) } + case .gif(let data, _): + return .gif(data) case .video(let url, _): return .other(url, fileExtension: url.pathExtension, mimeType: "video/mp4") } diff --git a/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/Attachment/AttachmentViewModel.swift b/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/Attachment/AttachmentViewModel.swift index e191f4d5..fd374a5d 100644 --- a/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/Attachment/AttachmentViewModel.swift +++ b/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/Attachment/AttachmentViewModel.swift @@ -9,7 +9,6 @@ import os.log import UIKit import Combine import PhotosUI -import TwidereCommon import Kingfisher final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable { @@ -47,6 +46,8 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable switch output { case .image(let data, _): return UIImage(data: data) + case .gif(let data, _): + return UIImage(data: data) case .video(let url, _): return AttachmentViewModel.createThumbnailForVideo(url: url) case .none: @@ -59,8 +60,10 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable deinit { switch output { case .image: - // FIXME: + // FIXME: any cleanup? break + case .gif(_, let url): + try? FileManager.default.removeItem(at: url) case .video(let url, _): try? FileManager.default.removeItem(at: url) case nil : @@ -79,7 +82,7 @@ extension AttachmentViewModel { public enum Output { case image(Data, imageKind: ImageKind) - // case gif(Data) + case gif(Data, URL) case video(URL, mimeType: String) // assert use file for video only public enum ImageKind { @@ -90,6 +93,7 @@ extension AttachmentViewModel { public var twitterMediaCategory: TwitterMediaCategory { switch self { case .image: return .image + case .gif: return .GIF case .video: return .amplifyVideo } } @@ -194,7 +198,12 @@ extension AttachmentViewModel { } private static func load(itemProvider: NSItemProvider) async throws -> Output { - if itemProvider.isImage() { + if itemProvider.isGIF() { + guard let result = try await itemProvider.loadGIFData() else { + throw AttachmentError.invalidAttachmentType + } + return .gif(result.data, result.url) + } else if itemProvider.isImage() { guard let result = try await itemProvider.loadImageData() else { throw AttachmentError.invalidAttachmentType } @@ -354,6 +363,14 @@ extension AttachmentViewModel: NSItemProviderWriting { default: completionHandler(nil, nil) } + case .gif(let data, _): + switch typeIdentifier { + case UTType.gif.identifier: + loadingProgress.completedUnitCount = 100 + completionHandler(data, nil) + default: + completionHandler(nil, nil) + } case .video(let url, _): switch typeIdentifier { case UTType.png.identifier: @@ -385,6 +402,13 @@ extension AttachmentViewModel: NSItemProviderWriting { } extension NSItemProvider { + fileprivate func isGIF() -> Bool { + return hasRepresentationConforming( + toTypeIdentifier: UTType.gif.identifier, + fileOptions: [] + ) + } + fileprivate func isImage() -> Bool { return hasRepresentationConforming( toTypeIdentifier: UTType.image.identifier, diff --git a/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/ComposeContentView.swift b/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/ComposeContentView.swift index 6fdb032a..f60b6532 100644 --- a/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/ComposeContentView.swift +++ b/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/ComposeContentView.swift @@ -14,14 +14,14 @@ import TwidereCore public struct ComposeContentView: View { - static let contentMargin: CGFloat = 16 + static let contentVerticalMargin: CGFloat = 6 static let contentRowTopPadding: CGFloat = 8 static let contentMetaTextViewHStackSpacing: CGFloat = 10 static let avatarSize = CGSize(width: 44, height: 44) + @ObservedObject var viewModel: ComposeContentViewModel - @State var mentionTextHeight: CGFloat = 0 @State var toolbarHeight: CGFloat = 0 @State var isPollExpireConfigurationPopoverPresent = false @@ -29,156 +29,60 @@ public struct ComposeContentView: View { let index: Int } @FocusState var pollField: PollField? + + var readableContentLayoutMargin: CGFloat { + abs(viewModel.viewLayoutFrame.readableContentLayoutFrame.origin.x) + } public var body: some View { ScrollView(.vertical, showsIndicators: false) { VStack(spacing: .zero) { // reply switch viewModel.kind { - case .reply(let status): - ReplyStatusViewRepresentable( - statusObject: status, - configurationContext: viewModel.configurationContext.statusViewConfigureContext, - width: viewModel.viewSize.width - 2 * ComposeContentView.contentMargin - ) - .padding(.top, 8) - .padding(.horizontal, ComposeContentView.contentMargin) - .frame(width: viewModel.viewSize.width) + case .reply: + if let replyToStatusViewModel = viewModel.replyToStatusViewModel { + StatusView(viewModel: replyToStatusViewModel) + } default: EmptyView() } // content HStack(alignment: .top, spacing: ComposeContentView.contentMetaTextViewHStackSpacing) { // avatar - AvatarButtonRepresentable(configuration: .init(url: viewModel.author?.avatarURL)) - .frame(width: ComposeContentView.avatarSize.width, height: ComposeContentView.avatarSize.height) - .overlay(alignment: .top) { - // draw conversation link line - switch viewModel.kind { - case .reply: - Rectangle() - .foregroundColor(Color(uiColor: .separator)) - .background(.clear) - .frame(width: 1, height: ComposeContentView.contentRowTopPadding) - .offset(x: 0, y: -ComposeContentView.contentRowTopPadding) - default: - EmptyView() - } - } + authorButtonView VStack { // mention if viewModel.isMentionPickDisplay { - Button { - viewModel.mentionPickPublisher.send() - } label: { - HStack(spacing: .zero) { - VectorImageView( - image: Asset.Communication.textBubbleSmall.image.withRenderingMode(.alwaysTemplate), - tintColor: .tintColor - ) - .frame(width: mentionTextHeight, height: mentionTextHeight, alignment: .center) - Text(viewModel.mentionPickButtonTitle) - .font(.footnote) - .background(GeometryReader { geometry in - Color.clear.preference( - key: SizeDimensionPreferenceKey.self, - value: geometry.size.height - ) - }) - .onPreferenceChange(SizeDimensionPreferenceKey.self) { - mentionTextHeight = $0 - } - Spacer() - } - } - } // end if + mentionPickerView + } // content warning if viewModel.isContentWarningComposing { - let contentWarningIconSize = CGSize(width: 24, height: 24) - let contentWarningStackSpacing: CGFloat = 8 - VStack { - HStack(spacing: contentWarningStackSpacing) { - VectorImageView( - image: Asset.Indices.exclamationmarkOctagon.image.withRenderingMode(.alwaysTemplate), - tintColor: viewModel.isContentWarningEditing ? .tintColor : .secondaryLabel - ) - .frame(width: contentWarningIconSize.width, height: contentWarningIconSize.height) - MetaTextViewRepresentable( - string: $viewModel.contentWarning, - width: { - var textViewWidth = viewModel.viewSize.width - textViewWidth -= ComposeContentView.contentMargin * 2 - textViewWidth -= ComposeContentView.contentMetaTextViewHStackSpacing - textViewWidth -= ComposeContentView.avatarSize.width - textViewWidth -= contentWarningIconSize.width - textViewWidth -= contentWarningStackSpacing - return textViewWidth - }(), - configurationHandler: { metaText in - viewModel.contentWarningMetaText = metaText - metaText.textView.attributedPlaceholder = { - var attributes = metaText.textAttributes - attributes[.foregroundColor] = UIColor.secondaryLabel - return NSAttributedString( - string: L10n.Scene.Compose.cwPlaceholder, - attributes: attributes - ) - }() - metaText.textView.tag = ComposeContentViewModel.MetaTextViewKind.contentWarning.rawValue - metaText.textView.returnKeyType = .next - metaText.textView.delegate = viewModel - metaText.textView.setContentHuggingPriority(.required - 1, for: .vertical) - metaText.delegate = viewModel - } - ) - } - Divider() - .background(viewModel.isContentWarningEditing ? .accentColor : Color(uiColor: .separator)) - } // end VStack - } // end if viewModel.isContentWarningComposing - // contentTextEditor - MetaTextViewRepresentable( - string: $viewModel.content, - width: { - var textViewWidth = viewModel.viewSize.width - textViewWidth -= ComposeContentView.contentMargin * 2 - textViewWidth -= ComposeContentView.contentMetaTextViewHStackSpacing - textViewWidth -= ComposeContentView.avatarSize.width - return textViewWidth - }(), - configurationHandler: { metaText in - viewModel.contentMetaText = metaText - metaText.textView.attributedPlaceholder = { - var attributes = metaText.textAttributes - attributes[.foregroundColor] = UIColor.secondaryLabel - return NSAttributedString( - string: L10n.Scene.Compose.placeholder, - attributes: attributes - ) - }() - metaText.textView.tag = ComposeContentViewModel.MetaTextViewKind.content.rawValue - metaText.textView.keyboardType = .twitter - metaText.textView.delegate = viewModel - metaText.delegate = viewModel - metaText.textView.becomeFirstResponder() - } - ) - .frame(minHeight: ComposeContentView.avatarSize.height) + contentWarningView + } + // content editor + contentEditorView // poll pollView - } - } // end content + } // end VStack + } // end HStack (content) + .frame(width: viewModel.viewLayoutFrame.readableContentLayoutFrame.width) .padding(.top, ComposeContentView.contentRowTopPadding) - .padding(.horizontal, ComposeContentView.contentMargin) - // mediaAttachment mediaAttachmentView - .padding(ComposeContentView.contentMargin) - + .padding(.horizontal, readableContentLayoutMargin) + .padding(.vertical, ComposeContentView.contentVerticalMargin) + // quote + if let quoteStatusViewModel = viewModel.quoteStatusViewModel { + StatusView(viewModel: quoteStatusViewModel) + .padding(.horizontal, readableContentLayoutMargin) + .padding(.vertical, 8) + .background(Color.primary.opacity(0.04)) + .padding(.vertical, ComposeContentView.contentVerticalMargin) + } Spacer() } // end VStack } // end ScrollView - .frame(width: viewModel.viewSize.width) + .frame(width: viewModel.viewLayoutFrame.layoutFrame.width) .frame(maxHeight: .infinity) .padding(.bottom, toolbarHeight) .contentShape(Rectangle()) @@ -212,6 +116,116 @@ public struct ComposeContentView: View { } extension ComposeContentView { + // MARK: - author button + var authorButtonView: some View { + AvatarButtonRepresentable(configuration: .init(url: viewModel.author?.avatarURL)) + .frame(width: ComposeContentView.avatarSize.width, height: ComposeContentView.avatarSize.height) + .overlay(alignment: .top) { + // draw conversation link line + switch viewModel.kind { + case .reply: + Rectangle() + .foregroundColor(Color(uiColor: .separator)) + .background(.clear) + .frame(width: 1, height: ComposeContentView.contentRowTopPadding) + .offset(x: 0, y: -ComposeContentView.contentRowTopPadding) + default: + EmptyView() + } + } + } // end var + + // MARK: - mention picker + var mentionPickerView: some View { + Button { + viewModel.mentionPickPublisher.send() + } label: { + HStack(spacing: .zero) { + Label { + Text(viewModel.mentionPickButtonTitle) + } icon: { + Image(systemName: "text.bubble") + } + .font(.footnote) + Spacer() + } + } + } + + // MARK: - content warning + var contentWarningView: some View { + VStack { + let contentWarningIconSize = CGSize(width: 24, height: 24) + let contentWarningStackSpacing: CGFloat = 8 + HStack(spacing: contentWarningStackSpacing) { + VectorImageView( + image: Asset.Indices.exclamationmarkOctagon.image.withRenderingMode(.alwaysTemplate), + tintColor: viewModel.isContentWarningEditing ? .tintColor : .secondaryLabel + ) + .frame(width: contentWarningIconSize.width, height: contentWarningIconSize.height) + MetaTextViewRepresentable( + string: $viewModel.contentWarning, + width: { + var textViewWidth = viewModel.viewLayoutFrame.readableContentLayoutFrame.width + textViewWidth -= ComposeContentView.contentMetaTextViewHStackSpacing + textViewWidth -= ComposeContentView.avatarSize.width + textViewWidth -= contentWarningIconSize.width + textViewWidth -= contentWarningStackSpacing + return textViewWidth + }(), + configurationHandler: { metaText in + viewModel.contentWarningMetaText = metaText + metaText.textView.attributedPlaceholder = { + var attributes = metaText.textAttributes + attributes[.foregroundColor] = UIColor.secondaryLabel + return NSAttributedString( + string: L10n.Scene.Compose.cwPlaceholder, + attributes: attributes + ) + }() + metaText.textView.tag = ComposeContentViewModel.MetaTextViewKind.contentWarning.rawValue + metaText.textView.returnKeyType = .next + metaText.textView.delegate = viewModel + metaText.textView.setContentHuggingPriority(.required - 1, for: .vertical) + metaText.delegate = viewModel + } + ) + } + Divider() + .background(viewModel.isContentWarningEditing ? .accentColor : Color(uiColor: .separator)) + } // end VStack + } + + // MARK: - content editor + var contentEditorView: some View { + MetaTextViewRepresentable( + string: $viewModel.content, + width: { + var textViewWidth = viewModel.viewLayoutFrame.readableContentLayoutFrame.width + textViewWidth -= ComposeContentView.contentMetaTextViewHStackSpacing + textViewWidth -= ComposeContentView.avatarSize.width + return textViewWidth + }(), + configurationHandler: { metaText in + viewModel.contentMetaText = metaText + metaText.textView.attributedPlaceholder = { + var attributes = metaText.textAttributes + attributes[.foregroundColor] = UIColor.secondaryLabel + return NSAttributedString( + string: L10n.Scene.Compose.placeholder, + attributes: attributes + ) + }() + metaText.textView.tag = ComposeContentViewModel.MetaTextViewKind.content.rawValue + metaText.textView.keyboardType = .twitter + metaText.textView.delegate = viewModel + metaText.delegate = viewModel + metaText.textView.becomeFirstResponder() + } + ) + .frame(minHeight: ComposeContentView.avatarSize.height) + } + // MARK: - attachment var mediaAttachmentView: some View { Group { @@ -372,19 +386,6 @@ extension ComposeContentView { } -private extension ComposeContentView { - struct SizeDimensionPreferenceKey: PreferenceKey { - static let defaultValue: CGFloat = 0 - - static func reduce( - value: inout CGFloat, - nextValue: () -> CGFloat - ) { - value = max(value, nextValue()) - } - } -} - // MARK: - TypeIdentifiedItemProvider extension PollComposeItem.Option: TypeIdentifiedItemProvider { public static var typeIdentifier: String { diff --git a/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/ComposeContentViewController.swift b/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/ComposeContentViewController.swift index 7743b18d..0867b372 100644 --- a/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/ComposeContentViewController.swift +++ b/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/ComposeContentViewController.swift @@ -58,7 +58,7 @@ extension ComposeContentViewController { super.viewDidLoad() view.backgroundColor = .systemBackground - viewModel.viewSize = view.frame.size + viewModel.viewLayoutFrame.update(view: view) customEmojiPickerInputView.delegate = self viewModel.setupDiffableDataSource( @@ -85,18 +85,19 @@ extension ComposeContentViewController { .receive(on: DispatchQueue.main) .sink { [weak self] _ in guard let self = self else { return } + guard let authContext = self.viewModel.authContext else { return } guard let primaryItem = self.viewModel.primaryMentionPickItem else { return } - + let mentionPickViewModel = MentionPickViewModel( - apiService: self.viewModel.configurationContext.apiService, - authenticationService: self.viewModel.configurationContext.authenticationService, + context: self.viewModel.context, + authContext: authContext, primaryItem: primaryItem, secondaryItems: self.viewModel.secondaryMentionPickItems ) let mentionPickViewController = MentionPickViewController() mentionPickViewController.viewModel = mentionPickViewModel mentionPickViewController.delegate = self - + let navigationController = AdaptiveStatusBarStyleNavigationController(rootViewController: mentionPickViewController) navigationController.modalPresentationStyle = .pageSheet if let sheetPresentationController = navigationController.sheetPresentationController { @@ -179,11 +180,15 @@ extension ComposeContentViewController { public override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - if viewModel.viewSize != view.frame.size { - viewModel.viewSize = view.frame.size - } + viewModel.viewLayoutFrame.update(view: view) } + public override func viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + + viewModel.viewLayoutFrame.update(view: view) + } + } extension ComposeContentViewController { diff --git a/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/ComposeContentViewModel+Diffable.swift b/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/ComposeContentViewModel+Diffable.swift index f1efa25e..4819b278 100644 --- a/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/ComposeContentViewModel+Diffable.swift +++ b/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/ComposeContentViewModel+Diffable.swift @@ -1,6 +1,6 @@ // // ComposeContentViewModel+Diffable.swift -// AppShared +// TwidereUI // // Created by MainasuK on 2021/11/17. // Copyright © 2021 Twidere. All rights reserved. diff --git a/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/ComposeContentViewModel+MetaTextDelegate.swift b/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/ComposeContentViewModel+MetaTextDelegate.swift index e453b695..95cd7a61 100644 --- a/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/ComposeContentViewModel+MetaTextDelegate.swift +++ b/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/ComposeContentViewModel+MetaTextDelegate.swift @@ -24,10 +24,6 @@ extension ComposeContentViewModel: MetaTextDelegate { _ metaText: MetaText, processEditing textStorage: MetaTextStorage ) -> MetaContent? { - guard let author = self.author else { - return nil - } - let kind = MetaTextViewKind(rawValue: metaText.textView.tag) ?? .none switch kind { @@ -39,13 +35,13 @@ extension ComposeContentViewModel: MetaTextDelegate { let textInput = textStorage.string self.content = textInput - switch author { + switch platform { case .twitter: - let content = TwitterContent(content: textInput) + let content = TwitterContent(content: textInput, urlEntities: []) let metaContent = TwitterMetaContent.convert( - content: content, + text: content, urlMaximumLength: .max, - twitterTextProvider: configurationContext.statusViewConfigureContext.twitterTextProvider + twitterTextProvider: SwiftTwitterTextProvider() ) return metaContent @@ -56,6 +52,9 @@ extension ComposeContentViewModel: MetaTextDelegate { ) let metaContent = MastodonMetaContent.convert(text: content) return metaContent + case .none: + assertionFailure() + return nil } case .contentWarning: diff --git a/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/ComposeContentViewModel.swift b/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/ComposeContentViewModel.swift index ae6cbaad..3acb420a 100644 --- a/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/ComposeContentViewModel.swift +++ b/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/ComposeContentViewModel.swift @@ -32,15 +32,25 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { }() // MARK: - layout - @Published var viewSize: CGSize = .zero + @Published public var viewLayoutFrame = ViewLayoutFrame() // input + let context: AppContext public let kind: Kind - public let configurationContext: ConfigurationContext public let customEmojiPickerInputViewModel = CustomEmojiPickerInputView.ViewModel() - + public let platform: Platform + + // Author (Me) + @Published public private(set) var authContext: AuthContext? + @Published public private(set) var author: UserObject? + // reply-to public private(set) var replyTo: StatusObject? + @Published public private(set) var replyToStatusViewModel: StatusView.ViewModel? + + // quote + public private(set) var quote: StatusObject? + @Published private(set) public var quoteStatusViewModel: StatusView.ViewModel? // limit @Published public var maxTextInputLimit = 500 @@ -76,9 +86,6 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { } } @Published public var isContentWarningEditing = false - - // avatar - @Published public var author: UserObject? // mention (Twitter) @Published public private(set) var isMentionPickDisplay = false @@ -140,7 +147,7 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { @Published public private(set) var availableActions: Set = Set() @Published public private(set) var isMediaToolBarButtonEnabled = true @Published public private(set) var isPollToolBarButtonEnabled = true - @Published public private(set) var isLocationToolBarButtonEnabled = CLLocationManager.locationServicesEnabled() + @Published public private(set) var isLocationToolBarButtonEnabled = false // UI state @Published public private(set) var isComposeBarButtonEnabled = true @@ -150,14 +157,21 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { let viewLayoutMarginDidUpdate = CurrentValueSubject(Void()) public init( + context: AppContext, + authContext: AuthContext, kind: Kind, - settings: Settings = Settings(), - configurationContext: ConfigurationContext + settings: Settings = Settings() ) { + self.context = context + self.authContext = authContext self.kind = kind - self.configurationContext = configurationContext + self.platform = authContext.authenticationContext.platform super.init() // end init + + Task { + isLocationToolBarButtonEnabled = CLLocationManager.locationServicesEnabled() + } switch kind { case .post: @@ -174,10 +188,19 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { } case .reply(let status): replyTo = status + replyToStatusViewModel = StatusView.ViewModel( + status: status, + authContext: nil, + kind: .referenceReplyTo, + delegate: nil, + viewLayoutFramePublisher: $viewLayoutFrame + ) + replyToStatusViewModel?.isBottomConversationLinkLineViewDisplay = true switch status { case .twitter(let status): // set mention + var usernames: [String] = [] self.primaryMentionPickItem = .twitterUser( username: status.author.username, attribute: MentionPickViewModel.Item.Attribute( @@ -189,10 +212,12 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { state: .finish ) ) + usernames.append(status.author.username) self.secondaryMentionPickItems = { var items: [MentionPickViewModel.Item] = [] - for mention in status.entities?.mentions ?? [] { + for mention in status.entitiesTransient?.mentions ?? [] { let username = mention.username + guard !usernames.contains(username) else { continue } let item = MentionPickViewModel.Item.twitterUser( username: username, attribute: .init( @@ -204,6 +229,7 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { state: .loading ) ) + usernames.append(username) items.append(item) } return items @@ -214,9 +240,46 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { isContentWarningComposing = true contentWarning = spoilerText } + + // set content text + var mentionAccts: [String] = [] + let _authorUserIdentifier: MastodonUserIdentifier? = { + switch authContext.authenticationContext.userIdentifier { + case .mastodon(let userIdentifier): return userIdentifier + default: return nil + } + }() + if _authorUserIdentifier?.id != status.author.id { + mentionAccts.append("@" + status.author.acct) + } + for mention in status.mentionsTransient { + let acct = "@" + mention.acct + guard !mentionAccts.contains(acct) else { continue } + guard mention.id != _authorUserIdentifier?.id else { continue } + mentionAccts.append(acct) + } + for acct in mentionAccts { + UITextChecker.learnWord(acct) + } + content = mentionAccts.joined(separator: " ") + " " } + + case .quote(let status): + quote = status + quoteStatusViewModel = StatusView.ViewModel( + status: status, + authContext: nil, + kind: .referenceQuote, + delegate: nil, + viewLayoutFramePublisher: $viewLayoutFrame + ) } + initialContent = content + + // bind author + author = authContext.authenticationContext.user(in: context.managedObjectContext) + // bind text $content .map { $0.isEmpty } @@ -231,7 +294,7 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { guard let author = author else { return } switch author { case .twitter: - let twitterTextProvider = configurationContext.statusViewConfigureContext.twitterTextProvider + let twitterTextProvider = SwiftTwitterTextProvider() let parseResult = twitterTextProvider.parse(text: content) self.contentWeightedLength = parseResult.weightedLength self.maxTextInputLimit = parseResult.maxWeightedLength @@ -317,7 +380,7 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { } excludeUsernames.insert(author.username) - for mention in status.entities?.mentions ?? [] { + for mention in status.entitiesTransient?.mentions ?? [] { guard !excludeUsernames.contains(mention.username) else { continue } usernames.append(mention.username) } @@ -326,7 +389,7 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { break } - let names = usernames.map { "@" + $0 } + let names = usernames.removingDuplicates().map { "@" + $0 } return ListFormatter.localizedString(byJoining: names) } .assign(to: &$mentionPickButtonTitle) @@ -409,7 +472,7 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { guard let self = self else { return nil } guard case let .mastodon(user) = author else { return nil } let domain = user.domain - guard let emojiViewModel = self.configurationContext.mastodonEmojiService.dequeueEmojiViewModel(for: domain) else { return nil } + guard let emojiViewModel = self.context.mastodonEmojiService.dequeueEmojiViewModel(for: domain) else { return nil } return emojiViewModel } .assign(to: &$emojiViewModel) @@ -427,20 +490,19 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { } .store(in: &disposeBag) - Publishers.CombineLatest( + Publishers.CombineLatest3( $isRequestLocation, - $currentLocation + $currentLocation, + $authContext ) - .asyncMap { [weak self] isRequestLocation, currentLocation -> Twitter.Entity.Place? in + .asyncMap { [weak self] isRequestLocation, currentLocation, authContext -> Twitter.Entity.Place? in guard let self = self else { return nil } guard isRequestLocation, let currentLocation = currentLocation else { return nil } - guard let authenticationContext = self.configurationContext.authenticationService.activeAuthenticationContext, - case let .twitter(twitterAuthenticationContext) = authenticationContext - else { return nil } + guard case let .twitter(twitterAuthenticationContext) = authContext?.authenticationContext else { return nil } do { - let response = try await self.configurationContext.apiService.geoSearch( + let response = try await self.context.apiService.geoSearch( latitude: currentLocation.coordinate.latitude, longitude: currentLocation.coordinate.longitude, granularity: "city", @@ -550,6 +612,7 @@ extension ComposeContentViewModel { case hashtag(hashtag: String) case mention(user: UserObject) case reply(status: StatusObject) + case quote(status: StatusObject) } public struct Settings { @@ -564,25 +627,7 @@ extension ComposeContentViewModel { self.mastodonVisibility = mastodonVisibility } } - - public struct ConfigurationContext { - public let apiService: APIService - public let authenticationService: AuthenticationService - public let mastodonEmojiService: MastodonEmojiService - public let statusViewConfigureContext: StatusView.ConfigurationContext - public init( - apiService: APIService, - authenticationService: AuthenticationService, - mastodonEmojiService: MastodonEmojiService, - statusViewConfigureContext: StatusView.ConfigurationContext - ) { - self.apiService = apiService - self.authenticationService = authenticationService - self.mastodonEmojiService = mastodonEmojiService - self.statusViewConfigureContext = statusViewConfigureContext - } - } } extension ComposeContentViewModel { @@ -695,7 +740,9 @@ extension ComposeContentViewModel { } public func statusPublisher() throws -> StatusPublisher { - guard let author = self.author else { + guard let authContext = self.authContext, + let author = self.author + else { throw AppError.implicit(.authenticationMissing) } @@ -713,13 +760,17 @@ extension ComposeContentViewModel { switch author { case .twitter(let author): return TwitterStatusPublisher( - apiService: configurationContext.apiService, + apiService: context.apiService, author: author, replyTo: { guard case let .twitter(status) = replyTo else { return nil } - return .init(objectID: status.objectID) + return status.asRecrod }(), excludeReplyUserIDs: Array(excludeReplyTwitterUserIDs), + quote: { + guard case let .twitter(status) = quote else { return nil } + return status.asRecrod + }(), content: content, place: isRequestLocation ? currentPlace : nil, poll: { @@ -740,6 +791,7 @@ extension ComposeContentViewModel { ) case .mastodon(let author): return MastodonStatusPublisher( + authContext: authContext, author: author, replyTo: { guard case let .mastodon(status) = replyTo else { return nil } diff --git a/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/Publisher/MastodonStatusPublisher.swift b/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/Publisher/MastodonStatusPublisher.swift index 306ca6e0..e9051464 100644 --- a/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/Publisher/MastodonStatusPublisher.swift +++ b/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/Publisher/MastodonStatusPublisher.swift @@ -7,7 +7,6 @@ import os.log import Foundation -import TwidereCommon import TwidereCore import MastodonSDK import CoreDataStack @@ -17,6 +16,7 @@ public final class MastodonStatusPublisher: NSObject, ProgressReporting { let logger = Logger(subsystem: "MastodonStatusPublisher", category: "Publisher") // Input + public let authContext: AuthContext // author public let author: MastodonUser @@ -45,6 +45,7 @@ public final class MastodonStatusPublisher: NSObject, ProgressReporting { public var state: Published.Publisher { $_state } public init( + authContext: AuthContext, author: MastodonUser, replyTo: ManagedObjectRecord?, isContentWarningComposing: Bool, @@ -58,6 +59,7 @@ public final class MastodonStatusPublisher: NSObject, ProgressReporting { pollMultipleConfiguration: PollComposeItem.MultipleConfiguration, visibility: Mastodon.Entity.Status.Visibility ) { + self.authContext = authContext self.author = author self.replyTo = replyTo self.isContentWarningComposing = isContentWarningComposing @@ -96,11 +98,8 @@ extension MastodonStatusPublisher: StatusPublisher { progress.totalUnitCount = taskCount progress.completedUnitCount = 0 - let _authenticationContext: MastodonAuthenticationContext? = await api.backgroundManagedObjectContext.perform { - guard let authentication = self.author.mastodonAuthentication else { return nil } - return MastodonAuthenticationContext(authentication: authentication) - } - guard let authenticationContext = _authenticationContext else { + guard case let .mastodon(authenticationContext) = authContext.authenticationContext else { + assertionFailure() throw AppError.implicit(.authenticationMissing) } diff --git a/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/Publisher/TwitterStatusPublisher.swift b/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/Publisher/TwitterStatusPublisher.swift index 0c0b1393..2fbde048 100644 --- a/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/Publisher/TwitterStatusPublisher.swift +++ b/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/Publisher/TwitterStatusPublisher.swift @@ -7,7 +7,6 @@ import os.log import Foundation -import TwidereCommon import TwidereCore import TwitterSDK import CoreDataStack @@ -20,9 +19,11 @@ public final class TwitterStatusPublisher: NSObject, ProgressReporting { // author public let author: TwitterUser - // refer + // refer reply-to public let replyTo: ManagedObjectRecord? public let excludeReplyUserIDs: [Twitter.Entity.V2.User.ID] + // refer quote + public let quote: ManagedObjectRecord? // status content public let content: String // location @@ -45,6 +46,7 @@ public final class TwitterStatusPublisher: NSObject, ProgressReporting { author: TwitterUser, replyTo: ManagedObjectRecord?, excludeReplyUserIDs: [Twitter.Entity.V2.User.ID], + quote: ManagedObjectRecord?, content: String, place: Twitter.Entity.Place?, poll: Twitter.API.V2.Status.Poll?, @@ -54,6 +56,7 @@ public final class TwitterStatusPublisher: NSObject, ProgressReporting { self.author = author self.replyTo = replyTo self.excludeReplyUserIDs = excludeReplyUserIDs + self.quote = quote self.content = content self.place = place self.poll = poll @@ -84,7 +87,7 @@ extension TwitterStatusPublisher: StatusPublisher { let managedObjectContext = api.backgroundManagedObjectContext - let _authenticationContext: TwitterAuthenticationContext? = await managedObjectContext.perform { + let _authenticationContext: TwitterAuthenticationContext? = await author.managedObjectContext?.perform { guard let authentication = self.author.twitterAuthentication else { return nil } return TwitterAuthenticationContext(authentication: authentication, secret: secret) } @@ -120,6 +123,7 @@ extension TwitterStatusPublisher: StatusPublisher { // Task: status let publishResponse = try await api.publishTwitterStatus( query: Twitter.API.V2.Status.PublishQuery( + forSuperFollowersOnly: nil, geo: { guard let place = self.place else { return nil } return .init(placeID: place.id) @@ -141,7 +145,15 @@ extension TwitterStatusPublisher: StatusPublisher { inReplyToTweetID: replyToID ) }(), - forSuperFollowersOnly: nil, + quoteTweetID: { + guard let quote = self.quote else { return nil } + let _quoteID: Twitter.Entity.V2.Tweet.ID? = await managedObjectContext.perform { + guard let quote = quote.object(in: managedObjectContext) else { return nil } + return quote.id + } + guard let quoteID = _quoteID else { return nil } + return quoteID + }(), replySettings: replySettings, text: content ), diff --git a/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/Reply/ReplyStatusView.swift b/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/Reply/ReplyStatusView.swift index e58553d0..05aa3d73 100644 --- a/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/Reply/ReplyStatusView.swift +++ b/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/Reply/ReplyStatusView.swift @@ -8,62 +8,62 @@ import UIKit import Combine -public final class ReplyStatusView: UIView { - - public var disposeBag = Set() - private var observations = Set() - - public let statusView = StatusView() - public private(set)var widthLayoutConstraint: NSLayoutConstraint! - - public let conversationLinkLineView = SeparatorLineView() - - public override var intrinsicContentSize: CGSize { - return statusView.frame.size - } - - public override init(frame: CGRect) { - super.init(frame: frame) - _init() - } - - public required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } - -} - -extension ReplyStatusView { - private func _init() { - widthLayoutConstraint = widthAnchor.constraint(equalToConstant: frame.width) - - statusView.translatesAutoresizingMaskIntoConstraints = false - addSubview(statusView) - NSLayoutConstraint.activate([ - statusView.topAnchor.constraint(equalTo: topAnchor), - statusView.leadingAnchor.constraint(equalTo: leadingAnchor), - trailingAnchor.constraint(equalTo: statusView.trailingAnchor), - statusView.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - statusView.setup(style: .composeReply) - - conversationLinkLineView.translatesAutoresizingMaskIntoConstraints = false - addSubview(conversationLinkLineView) - NSLayoutConstraint.activate([ - conversationLinkLineView.topAnchor.constraint(equalTo: statusView.authorAvatarButton.bottomAnchor, constant: 2), - conversationLinkLineView.centerXAnchor.constraint(equalTo: statusView.authorAvatarButton.centerXAnchor), - bottomAnchor.constraint(equalTo: conversationLinkLineView.bottomAnchor), - conversationLinkLineView.widthAnchor.constraint(equalToConstant: 1), - ]) - - // trigger UIViewRepresentable size update - statusView - .observe(\.bounds, options: [.initial, .new]) { [weak self] statusView, _ in - guard let self = self else { return } - print(statusView.frame) - self.invalidateIntrinsicContentSize() - } - .store(in: &observations) - } -} +//public final class ReplyStatusView: UIView { +// +// public var disposeBag = Set() +// private var observations = Set() +// +// public let statusView = StatusView() +// public private(set)var widthLayoutConstraint: NSLayoutConstraint! +// +// public let conversationLinkLineView = SeparatorLineView() +// +// public override var intrinsicContentSize: CGSize { +// return statusView.frame.size +// } +// +// public override init(frame: CGRect) { +// super.init(frame: frame) +// _init() +// } +// +// public required init?(coder: NSCoder) { +// super.init(coder: coder) +// _init() +// } +// +//} +// +//extension ReplyStatusView { +// private func _init() { +// widthLayoutConstraint = widthAnchor.constraint(equalToConstant: frame.width) +// +// statusView.translatesAutoresizingMaskIntoConstraints = false +// addSubview(statusView) +// NSLayoutConstraint.activate([ +// statusView.topAnchor.constraint(equalTo: topAnchor), +// statusView.leadingAnchor.constraint(equalTo: leadingAnchor), +// trailingAnchor.constraint(equalTo: statusView.trailingAnchor), +// statusView.bottomAnchor.constraint(equalTo: bottomAnchor), +// ]) +// statusView.setup(style: .composeReply) +// +// conversationLinkLineView.translatesAutoresizingMaskIntoConstraints = false +// addSubview(conversationLinkLineView) +// NSLayoutConstraint.activate([ +// conversationLinkLineView.topAnchor.constraint(equalTo: statusView.authorAvatarButton.bottomAnchor, constant: 2), +// conversationLinkLineView.centerXAnchor.constraint(equalTo: statusView.authorAvatarButton.centerXAnchor), +// bottomAnchor.constraint(equalTo: conversationLinkLineView.bottomAnchor), +// conversationLinkLineView.widthAnchor.constraint(equalToConstant: 1), +// ]) +// +// // trigger UIViewRepresentable size update +// statusView +// .observe(\.bounds, options: [.initial, .new]) { [weak self] statusView, _ in +// guard let self = self else { return } +// print(statusView.frame) +// self.invalidateIntrinsicContentSize() +// } +// .store(in: &observations) +// } +//} diff --git a/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView.swift b/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView.swift index d4ea966f..43d18ca6 100644 --- a/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView.swift +++ b/TwidereSDK/Sources/TwidereUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView.swift @@ -31,8 +31,7 @@ public struct ComposeContentToolbarView: View { // replySettings | visibility menu button switch viewModel.author { case .twitter: - EmptyView() - twitterReplySettingsMenuButton + twitterReplySettingsMenuButton case .mastodon: mastodonVisibilityMenuButton case .none: @@ -131,16 +130,16 @@ public struct ComposeContentToolbarView: View { var twitterReplySettingsMenuButton: some View { Menu { - ForEach(Twitter.Entity.V2.Tweet.ReplySettings.allCases, id: \.self) { replySetting in - Button { - viewModel.twitterReplySettings = replySetting - } label: { + Picker(selection: $viewModel.twitterReplySettings) { + ForEach(Twitter.Entity.V2.Tweet.ReplySettings.allCases, id: \.self) { replySetting in Label { Text(replySetting.title) } icon: { Image(uiImage: replySetting.image) } } + } label: { + Text(viewModel.twitterReplySettings.title) } } label: { HStack { @@ -155,8 +154,7 @@ public struct ComposeContentToolbarView: View { } .padding(.horizontal, 12) .contentShape(Rectangle()) - // do not padding vertical - // otherwise the poll, at, hashtag button tap area will be clipped + .ignoresSafeArea(.all, edges: .all) // fix label position jumping issue } } @@ -168,16 +166,16 @@ public struct ComposeContentToolbarView: View { .private, .direct, ] - ForEach(visibilities, id: \.self) { visibility in - Button { - viewModel.mastodonVisibility = visibility - } label: { + Picker(selection: $viewModel.mastodonVisibility) { + ForEach(visibilities, id: \.self) { visibility in Label { Text(visibility.title) } icon: { Image(uiImage: visibility.image) } } + } label: { + Text(viewModel.mastodonVisibility.title) } } label: { HStack { @@ -192,8 +190,7 @@ public struct ComposeContentToolbarView: View { } .padding(.horizontal, 12) .contentShape(Rectangle()) - // do not padding vertical - // otherwise the poll, at, hashtag button tap area will be clipped + .ignoresSafeArea(.all, edges: .all) // fix label position jumping issue } } diff --git a/TwidereSDK/Sources/TwidereUI/Scene/MentionPick/MentionPickViewController.swift b/TwidereSDK/Sources/TwidereUI/Scene/MentionPick/MentionPickViewController.swift index f7db3e06..a3b4d38b 100644 --- a/TwidereSDK/Sources/TwidereUI/Scene/MentionPick/MentionPickViewController.swift +++ b/TwidereSDK/Sources/TwidereUI/Scene/MentionPick/MentionPickViewController.swift @@ -9,7 +9,6 @@ import os.log import UIKit import Combine import TwidereLocalization -import TwidereUI protocol MentionPickViewControllerDelegate: AnyObject { func mentionPickViewController(_ controller: MentionPickViewController, itemPickDidChange items: [MentionPickViewModel.Item]) @@ -51,9 +50,11 @@ extension MentionPickViewController { tableView.delegate = self viewModel.setupDiffableDataSource( - for: tableView, - configuration: MentionPickViewModel.DataSourceConfiguration( - userTableViewCellDelegate: self + tableView: tableView, + context: viewModel.context, + authContext: viewModel.authContext, + configuration: .init( + userViewTableViewCellDelegate: self ) ) } @@ -102,23 +103,19 @@ extension MentionPickViewController: UITableViewDelegate { // MARK: - UserTableViewCellDelegate extension MentionPickViewController: UserViewTableViewCellDelegate { - public func tableViewCell(_ cell: UITableViewCell, userView: UserView, menuActionDidPressed action: UserView.MenuAction, menuButton button: UIButton) { - // do nothing - } - - public func tableViewCell(_ cell: UITableViewCell, userView: UserView, friendshipButtonDidPressed button: UIButton) { - // do nothing + public func tableViewCell(_ cell: UITableViewCell, viewModel: UserView.ViewModel, userAvatarButtonDidPressed user: UserRecord) { + } - public func tableViewCell(_ cell: UITableViewCell, userView: UserView, membershipButtonDidPressed button: UIButton) { - // do nothing + public func tableViewCell(_ cell: UITableViewCell, viewModel: UserView.ViewModel, menuActionDidPressed action: UserView.ViewModel.MenuAction) { + } - public func tableViewCell(_ cell: UITableViewCell, userView: UserView, acceptFollowReqeustButtonDidPressed button: UIButton) { - // do nothing + public func tableViewCell(_ cell: UITableViewCell, viewModel: UserView.ViewModel, listMembershipButtonDidPressed user: UserRecord) { + } - public func tableViewCell(_ cell: UITableViewCell, userView: UserView, rejectFollowReqeustButtonDidPressed button: UIButton) { - // do nothing + public func tableViewCell(_ cell: UITableViewCell, viewModel: UserView.ViewModel, followReqeustButtonDidPressed user: UserRecord, accept: Bool) { + } } diff --git a/TwidereSDK/Sources/TwidereUI/Scene/MentionPick/MentionPickViewModel+Diffable.swift b/TwidereSDK/Sources/TwidereUI/Scene/MentionPick/MentionPickViewModel+Diffable.swift index 4bb8b36e..f24761e8 100644 --- a/TwidereSDK/Sources/TwidereUI/Scene/MentionPick/MentionPickViewModel+Diffable.swift +++ b/TwidereSDK/Sources/TwidereUI/Scene/MentionPick/MentionPickViewModel+Diffable.swift @@ -6,30 +6,44 @@ // import UIKit +import SwiftUI import CoreData -import TwidereUI +import MetaTextKit extension MentionPickViewModel { - struct DataSourceConfiguration { - weak var userTableViewCellDelegate: UserViewTableViewCellDelegate? - } + struct Configuration { + weak var userViewTableViewCellDelegate: UserViewTableViewCellDelegate? + } + func setupDiffableDataSource( - for tableView: UITableView, - configuration: DataSourceConfiguration + tableView: UITableView, + context: AppContext, + authContext: AuthContext, + configuration: Configuration ) { - tableView.register(UserMentionPickStyleTableViewCell.self, forCellReuseIdentifier: String(describing: UserMentionPickStyleTableViewCell.self)) - + tableView.register(UserTableViewCell.self, forCellReuseIdentifier: String(describing: UserTableViewCell.self)) + diffableDataSource = UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: UserMentionPickStyleTableViewCell.self), for: indexPath) as! UserMentionPickStyleTableViewCell - MentionPickViewModel.configure( - cell: cell, + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: UserTableViewCell.self), for: indexPath) as! UserTableViewCell + cell.userViewTableViewCellDelegate = configuration.userViewTableViewCellDelegate + + let viewModel = UserView.ViewModel( item: item, - configuration: configuration + delegate: cell ) + cell.contentConfiguration = UIHostingConfiguration { + UserView(viewModel: viewModel) + } + .margins(.vertical, 0) // remove vertical margins + switch item { + case .twitterUser(_, let attribute): + cell.selectionStyle = attribute.disabled ? .none : .default + } + return cell } - + var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.primary]) snapshot.appendItems([primaryItem], toSection: .primary) @@ -44,30 +58,30 @@ extension MentionPickViewModel { extension MentionPickViewModel { // FIXME: use UserRecord bind view - static func configure( - cell: UserMentionPickStyleTableViewCell, - item: Item, - configuration: DataSourceConfiguration - ) { - switch item { - case .twitterUser(let username, let attribute): - cell.userView.viewModel.platform = .twitter - cell.userView.viewModel.avatarImageURL = attribute.avatarImageURL - cell.userView.viewModel.name = attribute.name.flatMap { PlaintextMetaContent(string: $0) } - cell.userView.viewModel.username = "@" + username - - cell.userView.activityIndicatorView.isHidden = attribute.state == .finish - cell.userView.checkmarkButton.isHidden = attribute.state == .loading - - if attribute.selected { - cell.userView.checkmarkButton.setImage(UIImage(systemName: "checkmark.circle.fill"), for: .normal) - } else { - cell.userView.checkmarkButton.setImage(UIImage(systemName: "circle"), for: .normal) - } - cell.selectionStyle = attribute.disabled ? .none : .default - cell.userView.checkmarkButton.tintColor = attribute.disabled ? .systemGray : (attribute.selected ? Asset.Colors.hightLight.color : .systemGray) - } // end switch - } +// static func configure( +// cell: UserMentionPickStyleTableViewCell, +// item: Item, +// configuration: DataSourceConfiguration +// ) { +// switch item { +// case .twitterUser(let username, let attribute): +// cell.userView.viewModel.platform = .twitter +// cell.userView.viewModel.avatarImageURL = attribute.avatarImageURL +// cell.userView.viewModel.name = attribute.name.flatMap { PlaintextMetaContent(string: $0) } +// cell.userView.viewModel.username = "@" + username +// +// cell.userView.activityIndicatorView.isHidden = attribute.state == .finish +// cell.userView.checkmarkButton.isHidden = attribute.state == .loading +// +// if attribute.selected { +// cell.userView.checkmarkButton.setImage(UIImage(systemName: "checkmark.circle.fill"), for: .normal) +// } else { +// cell.userView.checkmarkButton.setImage(UIImage(systemName: "circle"), for: .normal) +// } +// cell.selectionStyle = attribute.disabled ? .none : .default +// cell.userView.checkmarkButton.tintColor = attribute.disabled ? .systemGray : (attribute.selected ? Asset.Colors.hightLight.color : .systemGray) +// } // end switch +// } } diff --git a/TwidereSDK/Sources/TwidereUI/Scene/MentionPick/MentionPickViewModel.swift b/TwidereSDK/Sources/TwidereUI/Scene/MentionPick/MentionPickViewModel.swift index fd4414b3..2d6c3246 100644 --- a/TwidereSDK/Sources/TwidereUI/Scene/MentionPick/MentionPickViewModel.swift +++ b/TwidereSDK/Sources/TwidereUI/Scene/MentionPick/MentionPickViewModel.swift @@ -18,8 +18,8 @@ public final class MentionPickViewModel { var disposeBag = Set() // input - let apiService: APIService - let authenticationService: AuthenticationService + let context: AppContext + let authContext: AuthContext let primaryItem: Item let secondaryItems: [Item] @@ -27,29 +27,25 @@ public final class MentionPickViewModel { var diffableDataSource: UITableViewDiffableDataSource? init( - apiService: APIService, - authenticationService: AuthenticationService, + context: AppContext, + authContext: AuthContext, primaryItem: Item, secondaryItems: [Item] ) { - self.apiService = apiService - self.authenticationService = authenticationService + self.context = context + self.authContext = authContext self.primaryItem = primaryItem - self.secondaryItems = secondaryItems + self.secondaryItems = secondaryItems.removingDuplicates() + // end init - authenticationService.$activeAuthenticationContext - .sink { [weak self] authenticationContext in - guard let self = self else { return } - switch authenticationContext { - case .twitter(let twitterAuthenticationContext): - Task { - try await self.resolveLoadingItems(twitterAuthenticationContext: twitterAuthenticationContext) - } - default: - break - } + switch authContext.authenticationContext { + case .twitter(let authenticationContext): + Task { + try await self.resolveLoadingItems(twitterAuthenticationContext: authenticationContext) } - .store(in: &disposeBag) + case .mastodon: + break + } } deinit { @@ -66,14 +62,27 @@ extension MentionPickViewModel { public enum Item: Hashable { case twitterUser(username: String, attribute: Attribute) + + var id: String { + switch self { + case .twitterUser(let username, _): + return username + } + } + + public static func == (lhs: MentionPickViewModel.Item, rhs: MentionPickViewModel.Item) -> Bool { + return lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } } } extension MentionPickViewModel.Item { - public class Attribute: Hashable { + public class Attribute { - public let id = UUID() - public var state: State = .loading // input @@ -105,14 +114,9 @@ extension MentionPickViewModel.Item { return lhs.state == rhs.state && lhs.disabled == rhs.disabled && lhs.selected == rhs.selected && - lhs.avatarImageURL == rhs.avatarImageURL && - lhs.userID == rhs.userID + lhs.avatarImageURL == rhs.avatarImageURL } - - public func hash(into hasher: inout Hasher) { - hasher.combine(id) - } - + } } @@ -138,7 +142,7 @@ extension MentionPickViewModel { } } - let response = try await apiService.twitterUsers( + let response = try await context.apiService.twitterUsers( usernames: usernames, twitterAuthenticationContext: twitterAuthenticationContext ) diff --git a/TwidereSDK/Sources/TwidereUI/Shape/AvatarClipShape.swift b/TwidereSDK/Sources/TwidereUI/Shape/AvatarClipShape.swift new file mode 100644 index 00000000..4bdc16f3 --- /dev/null +++ b/TwidereSDK/Sources/TwidereUI/Shape/AvatarClipShape.swift @@ -0,0 +1,48 @@ +// +// AvatarClipShape.swift +// +// +// Created by MainasuK on 2023/4/20. +// + +import SwiftUI + +public struct AvatarClipShape: Shape, Animatable { + var avatarStyle: UserDefaults.AvatarStyle + var progress: CGFloat + + public var animatableData: CGFloat { + get { + switch avatarStyle { + case .circle: return 0.0 + case .roundedSquare: return 1.0 + } + } + set { progress = newValue } + } + + public init(avatarStyle: UserDefaults.AvatarStyle) { + self.avatarStyle = avatarStyle + self.progress = { + switch avatarStyle { + case .circle: return 0.0 + case .roundedSquare: return 1.0 + } + }() + // end init + } + + public func path(in rect: CGRect) -> Path { + let cornerRadius = lerp(v0: rect.width / 2, v1: rect.width / 4, t: progress) + return RoundedRectangle(cornerRadius: cornerRadius).path(in: rect) + } + +} + +extension AvatarClipShape { + + // linear interpolation + func lerp(v0: CGFloat, v1: CGFloat, t: CGFloat) -> CGFloat { + return (1 - t) * v0 + (t * v1) + } +} diff --git a/TwidereSDK/Sources/TwidereUI/Shape/CheckmarkView.swift b/TwidereSDK/Sources/TwidereUI/Shape/CheckmarkView.swift new file mode 100644 index 00000000..341dfe68 --- /dev/null +++ b/TwidereSDK/Sources/TwidereUI/Shape/CheckmarkView.swift @@ -0,0 +1,82 @@ +// +// CheckmarkView.swift +// +// +// Created by MainasuK on 2023/3/21. +// + +import Foundation +import SwiftUI + +public struct CheckmarkView: View { + + public let tintColor: UIColor + public let borderWidth: CGFloat + public let cornerRadius: CGFloat + public let check: Bool + + + public var body: some View { + ZStack { + Color(uiColor: tintColor) + if check { + CheckmarkShape() + .blendMode(.destinationOut) + } else { + Color(uiColor: tintColor) + .cornerRadius(cornerRadius - borderWidth) + .padding(borderWidth) + .blendMode(.destinationOut) + } + } + .compositingGroup() + .cornerRadius(cornerRadius) + } +} + +struct CheckmarkShape: Shape { + + func path(in rect: CGRect) -> Path { + let width = rect.width + let height = rect.height + let radius = width / 20 + + var path = Path() + let cos45 = cos(45.0 * CGFloat.pi / 180.0) + let sin45 = cos(45.0 * CGFloat.pi / 180.0) + let root2 = 2.squareRoot() + path.addArc(center: CGPoint(x: 9/20 * width, y: 12/20 * height), radius: radius, startAngle: Angle(degrees: 45), endAngle: Angle(degrees: 135), clockwise: false) + path.addLine(to: CGPoint(x: 7/20 * width - cos45 * radius, y: 10/20 * height + sin45 * radius)) + path.addArc(center: CGPoint(x: 7/20 * width, y: 10/20 * height), radius: radius, startAngle: Angle(degrees: 135), endAngle: Angle(degrees: 315), clockwise: false) + path.addLine(to: CGPoint(x: 9/20 * width, y: 12/20 * height - root2 * radius)) + path.addArc(center: CGPoint(x: 13/20 * width, y: 8/20 * height), radius: radius, startAngle: Angle(degrees: 225), endAngle: Angle(degrees: 405), clockwise: false) + path.closeSubpath() + return path + } +} + + +#if DEBUG +import SwiftUI +struct CheckmarkView_Preview: PreviewProvider { + + static var width: CGFloat = 100 + + static var previews: some View { + CheckmarkView( + tintColor: .systemBlue, + borderWidth: width / 18, + cornerRadius: width / 4, + check: true + ) + .previewLayout(.fixed(width: width, height: width)) + CheckmarkView( + tintColor: .systemBlue, + borderWidth: width / 18, + cornerRadius: width / 4, + check: false + ) + .previewLayout(.fixed(width: width, height: width)) + } +} +#endif diff --git a/TwidereSDK/Sources/TwidereUI/TableViewCell/TableView/TableSlideTableViewCell.swift b/TwidereSDK/Sources/TwidereUI/TableViewCell/Common/TableSlideTableViewCell.swift similarity index 100% rename from TwidereSDK/Sources/TwidereUI/TableViewCell/TableView/TableSlideTableViewCell.swift rename to TwidereSDK/Sources/TwidereUI/TableViewCell/Common/TableSlideTableViewCell.swift diff --git a/TwidereSDK/Sources/TwidereUI/TableViewCell/TableView/TableViewCheckmarkTableViewCell.swift b/TwidereSDK/Sources/TwidereUI/TableViewCell/Common/TableViewCheckmarkTableViewCell.swift similarity index 97% rename from TwidereSDK/Sources/TwidereUI/TableViewCell/TableView/TableViewCheckmarkTableViewCell.swift rename to TwidereSDK/Sources/TwidereUI/TableViewCell/Common/TableViewCheckmarkTableViewCell.swift index f545be10..ef6c1646 100644 --- a/TwidereSDK/Sources/TwidereUI/TableViewCell/TableView/TableViewCheckmarkTableViewCell.swift +++ b/TwidereSDK/Sources/TwidereUI/TableViewCell/Common/TableViewCheckmarkTableViewCell.swift @@ -7,7 +7,6 @@ // import UIKit - public final class TableViewCheckmarkTableViewCell: TableViewPlainCell { override func _init() { @@ -21,6 +20,7 @@ public final class TableViewCheckmarkTableViewCell: TableViewPlainCell { #if canImport(SwiftUI) && DEBUG import SwiftUI import TwidereCore +import MetaTextKit struct ListCheckmarkTableViewCell_Previews: PreviewProvider { diff --git a/TwidereSDK/Sources/TwidereUI/TableViewCell/TableView/TableViewEntryTableViewCell.swift b/TwidereSDK/Sources/TwidereUI/TableViewCell/Common/TableViewEntryTableViewCell.swift similarity index 97% rename from TwidereSDK/Sources/TwidereUI/TableViewCell/TableView/TableViewEntryTableViewCell.swift rename to TwidereSDK/Sources/TwidereUI/TableViewCell/Common/TableViewEntryTableViewCell.swift index 4d29e168..f1561dae 100644 --- a/TwidereSDK/Sources/TwidereUI/TableViewCell/TableView/TableViewEntryTableViewCell.swift +++ b/TwidereSDK/Sources/TwidereUI/TableViewCell/Common/TableViewEntryTableViewCell.swift @@ -22,6 +22,7 @@ public final class TableViewEntryTableViewCell: TableViewPlainCell { #if canImport(SwiftUI) && DEBUG import SwiftUI import TwidereCore +import MetaTextKit struct ListEntryTableViewCell_Previews: PreviewProvider { diff --git a/TwidereSDK/Sources/TwidereUI/TableViewCell/TableView/TableViewPlainCell.swift b/TwidereSDK/Sources/TwidereUI/TableViewCell/Common/TableViewPlainCell.swift similarity index 99% rename from TwidereSDK/Sources/TwidereUI/TableViewCell/TableView/TableViewPlainCell.swift rename to TwidereSDK/Sources/TwidereUI/TableViewCell/Common/TableViewPlainCell.swift index 05c83be3..736640df 100644 --- a/TwidereSDK/Sources/TwidereUI/TableViewCell/TableView/TableViewPlainCell.swift +++ b/TwidereSDK/Sources/TwidereUI/TableViewCell/Common/TableViewPlainCell.swift @@ -8,6 +8,7 @@ import UIKit import MetaTextKit +import MetaLabel public class TableViewPlainCell: UITableViewCell { diff --git a/TwidereSDK/Sources/TwidereUI/TableViewCell/TableView/TableViewSwitchTableViewCell.swift b/TwidereSDK/Sources/TwidereUI/TableViewCell/Common/TableViewSwitchTableViewCell.swift similarity index 100% rename from TwidereSDK/Sources/TwidereUI/TableViewCell/TableView/TableViewSwitchTableViewCell.swift rename to TwidereSDK/Sources/TwidereUI/TableViewCell/Common/TableViewSwitchTableViewCell.swift diff --git a/TwidereSDK/Sources/TwidereUI/TableViewCell/TableView/TableViewTextFieldTableViewCell.swift b/TwidereSDK/Sources/TwidereUI/TableViewCell/Common/TableViewTextFieldTableViewCell.swift similarity index 100% rename from TwidereSDK/Sources/TwidereUI/TableViewCell/TableView/TableViewTextFieldTableViewCell.swift rename to TwidereSDK/Sources/TwidereUI/TableViewCell/Common/TableViewTextFieldTableViewCell.swift diff --git a/TwidereX/Scene/Search/SearchResult/Hashtag/Cell/HashtagTableViewCell+ViewModel.swift b/TwidereSDK/Sources/TwidereUI/TableViewCell/Hashtag/HashtagTableViewCell+ViewModel.swift similarity index 89% rename from TwidereX/Scene/Search/SearchResult/Hashtag/Cell/HashtagTableViewCell+ViewModel.swift rename to TwidereSDK/Sources/TwidereUI/TableViewCell/Hashtag/HashtagTableViewCell+ViewModel.swift index 4e1d3c78..1e3e13b3 100644 --- a/TwidereX/Scene/Search/SearchResult/Hashtag/Cell/HashtagTableViewCell+ViewModel.swift +++ b/TwidereSDK/Sources/TwidereUI/TableViewCell/Hashtag/HashtagTableViewCell+ViewModel.swift @@ -10,6 +10,7 @@ import UIKit import Combine import Meta import MastodonSDK +import TwidereCore extension HashtagTableViewCell { final class ViewModel: ObservableObject { @@ -38,16 +39,16 @@ extension HashtagTableViewCell.ViewModel { } extension HashtagTableViewCell { - func configure(hashtagData: HashtagData) { + public func configure(hashtagData: HashtagData) { switch hashtagData { case .mastodon(let tag): configure(tag: tag) } } - func configure(tag: Mastodon.Entity.Tag) { + public func configure(tag: Mastodon.Entity.Tag) { // primary - let primaryContent = Meta.convert(from: .plaintext(string: "#" + tag.name)) + let primaryContent = Meta.convert(document: .plaintext(string: "#" + tag.name)) viewModel.primaryContent = primaryContent // secondary let count = tag.history?.sorted(by: { $0.day < $1.day }) diff --git a/TwidereX/Scene/Search/SearchResult/Hashtag/Cell/HashtagTableViewCell.swift b/TwidereSDK/Sources/TwidereUI/TableViewCell/Hashtag/HashtagTableViewCell.swift similarity index 96% rename from TwidereX/Scene/Search/SearchResult/Hashtag/Cell/HashtagTableViewCell.swift rename to TwidereSDK/Sources/TwidereUI/TableViewCell/Hashtag/HashtagTableViewCell.swift index 31b7e355..508e4847 100644 --- a/TwidereX/Scene/Search/SearchResult/Hashtag/Cell/HashtagTableViewCell.swift +++ b/TwidereSDK/Sources/TwidereUI/TableViewCell/Hashtag/HashtagTableViewCell.swift @@ -7,9 +7,11 @@ // import UIKit +import TwidereCore import MetaTextKit +import MetaLabel -final class HashtagTableViewCell: UITableViewCell { +final public class HashtagTableViewCell: UITableViewCell { let primaryLabel = MetaLabel(style: .hashtagTitle) let secondaryLabel = MetaLabel(style: .hashtagDescription) diff --git a/TwidereX/Scene/Share/View/TableViewCell/TimelineBottomLoaderTableViewCell.swift b/TwidereSDK/Sources/TwidereUI/TableViewCell/Loader/TimelineBottomLoaderTableViewCell.swift similarity index 75% rename from TwidereX/Scene/Share/View/TableViewCell/TimelineBottomLoaderTableViewCell.swift rename to TwidereSDK/Sources/TwidereUI/TableViewCell/Loader/TimelineBottomLoaderTableViewCell.swift index 1df29881..e394fdcd 100644 --- a/TwidereX/Scene/Share/View/TableViewCell/TimelineBottomLoaderTableViewCell.swift +++ b/TwidereSDK/Sources/TwidereUI/TableViewCell/Loader/TimelineBottomLoaderTableViewCell.swift @@ -8,9 +8,9 @@ import UIKit import Combine -final class TimelineBottomLoaderTableViewCell: TimelineLoaderTableViewCell { +public final class TimelineBottomLoaderTableViewCell: TimelineLoaderTableViewCell { - override func prepareForReuse() { + public override func prepareForReuse() { super.prepareForReuse() activityIndicatorView.startAnimating() diff --git a/TwidereX/Scene/Share/View/TableViewCell/TimelineLoaderTableViewCell.swift b/TwidereSDK/Sources/TwidereUI/TableViewCell/Loader/TimelineLoaderTableViewCell.swift similarity index 86% rename from TwidereX/Scene/Share/View/TableViewCell/TimelineLoaderTableViewCell.swift rename to TwidereSDK/Sources/TwidereUI/TableViewCell/Loader/TimelineLoaderTableViewCell.swift index 576dfc1d..593b562c 100644 --- a/TwidereX/Scene/Share/View/TableViewCell/TimelineLoaderTableViewCell.swift +++ b/TwidereSDK/Sources/TwidereUI/TableViewCell/Loader/TimelineLoaderTableViewCell.swift @@ -8,10 +8,11 @@ import UIKit import Combine +import TwidereCore -class TimelineLoaderTableViewCell: UITableViewCell { +public class TimelineLoaderTableViewCell: UITableViewCell { - static let cellHeight: CGFloat = 48 + public static let cellHeight: CGFloat = 48 var disposeBag = Set() @@ -25,24 +26,24 @@ class TimelineLoaderTableViewCell: UITableViewCell { return button }() - let activityIndicatorView: UIActivityIndicatorView = { + public let activityIndicatorView: UIActivityIndicatorView = { let activityIndicatorView = UIActivityIndicatorView(style: .medium) activityIndicatorView.tintColor = .systemFill activityIndicatorView.hidesWhenStopped = true return activityIndicatorView }() - override func prepareForReuse() { + public override func prepareForReuse() { super.prepareForReuse() disposeBag.removeAll() } - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + public override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) _init() } - required init?(coder: NSCoder) { + public required init?(coder: NSCoder) { super.init(coder: coder) _init() } diff --git a/TwidereX/Scene/Share/View/TableViewCell/TimelineMiddleLoaderTableViewCell+ViewModel.swift b/TwidereSDK/Sources/TwidereUI/TableViewCell/Loader/TimelineMiddleLoaderTableViewCell+ViewModel.swift similarity index 94% rename from TwidereX/Scene/Share/View/TableViewCell/TimelineMiddleLoaderTableViewCell+ViewModel.swift rename to TwidereSDK/Sources/TwidereUI/TableViewCell/Loader/TimelineMiddleLoaderTableViewCell+ViewModel.swift index 9745bbf5..ab4ff8f5 100644 --- a/TwidereX/Scene/Share/View/TableViewCell/TimelineMiddleLoaderTableViewCell+ViewModel.swift +++ b/TwidereSDK/Sources/TwidereUI/TableViewCell/Loader/TimelineMiddleLoaderTableViewCell+ViewModel.swift @@ -12,10 +12,10 @@ import SwiftUI import CoreDataStack extension TimelineMiddleLoaderTableViewCell { - class ViewModel { + public class ViewModel { var disposeBag = Set() - @Published var isFetching = false + @Published public var isFetching = false } func configure( diff --git a/TwidereX/Scene/Share/View/TableViewCell/TimelineMiddleLoaderTableViewCell.swift b/TwidereSDK/Sources/TwidereUI/TableViewCell/Loader/TimelineMiddleLoaderTableViewCell.swift similarity index 90% rename from TwidereX/Scene/Share/View/TableViewCell/TimelineMiddleLoaderTableViewCell.swift rename to TwidereSDK/Sources/TwidereUI/TableViewCell/Loader/TimelineMiddleLoaderTableViewCell.swift index 5ecc40ca..b1d49955 100644 --- a/TwidereX/Scene/Share/View/TableViewCell/TimelineMiddleLoaderTableViewCell.swift +++ b/TwidereSDK/Sources/TwidereUI/TableViewCell/Loader/TimelineMiddleLoaderTableViewCell.swift @@ -10,15 +10,15 @@ import UIKit import Combine import CoreData -protocol TimelineMiddleLoaderTableViewCellDelegate: AnyObject { +public protocol TimelineMiddleLoaderTableViewCellDelegate: AnyObject { func timelineMiddleLoaderTableViewCell(_ cell: TimelineMiddleLoaderTableViewCell, loadMoreButtonDidPressed button: UIButton) } -final class TimelineMiddleLoaderTableViewCell: TimelineLoaderTableViewCell { +public final class TimelineMiddleLoaderTableViewCell: TimelineLoaderTableViewCell { weak var delegate: TimelineMiddleLoaderTableViewCellDelegate? - private(set) lazy var viewModel: ViewModel = { + public private(set) lazy var viewModel: ViewModel = { let viewModel = ViewModel() viewModel.bind(cell: self) return viewModel diff --git a/TwidereX/Scene/Share/View/TableViewCell/TimelineTopLoaderTableViewCell.swift b/TwidereSDK/Sources/TwidereUI/TableViewCell/Loader/TimelineTopLoaderTableViewCell.swift similarity index 69% rename from TwidereX/Scene/Share/View/TableViewCell/TimelineTopLoaderTableViewCell.swift rename to TwidereSDK/Sources/TwidereUI/TableViewCell/Loader/TimelineTopLoaderTableViewCell.swift index f8f6fe34..244e89fc 100644 --- a/TwidereX/Scene/Share/View/TableViewCell/TimelineTopLoaderTableViewCell.swift +++ b/TwidereSDK/Sources/TwidereUI/TableViewCell/Loader/TimelineTopLoaderTableViewCell.swift @@ -9,4 +9,4 @@ import UIKit import Combine -final class TimelineTopLoaderTableViewCell: TimelineLoaderTableViewCell { } +public final class TimelineTopLoaderTableViewCell: TimelineLoaderTableViewCell { } diff --git a/TwidereSDK/Sources/TwidereUI/TableViewCell/Notification/NotificationTableViewCell.swift b/TwidereSDK/Sources/TwidereUI/TableViewCell/Notification/NotificationTableViewCell.swift new file mode 100644 index 00000000..d63abe9c --- /dev/null +++ b/TwidereSDK/Sources/TwidereUI/TableViewCell/Notification/NotificationTableViewCell.swift @@ -0,0 +1,57 @@ +// +// NotificationTableViewCell.swift +// +// +// Created by MainasuK on 2023/4/11. +// + +import os.log +import UIKit + +public final class NotificationTableViewCell: UITableViewCell { + + let logger = Logger(subsystem: "StatusTableViewCell", category: "View") + + public weak var statusViewTableViewCellDelegate: StatusViewTableViewCellDelegate? + public weak var userViewTableViewCellDelegate: UserViewTableViewCellDelegate? + public var viewModel: NotificationView.ViewModel? + + public override func prepareForReuse() { + super.prepareForReuse() + + contentConfiguration = nil + statusViewTableViewCellDelegate = nil + userViewTableViewCellDelegate = nil + } + + public override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + public required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension NotificationTableViewCell { + + private func _init() { + selectionStyle = .none + } + +} + +// MARK: - StatusViewContainerTableViewCell +extension NotificationTableViewCell: StatusViewContainerTableViewCell { } + +// MARK: - StatusViewDelegate +extension NotificationTableViewCell: StatusViewDelegate { } + +// MARK: - UserViewContainerTableViewCell +extension NotificationTableViewCell: UserViewContainerTableViewCell { } + +// MARK: - UserViewDelegate +extension NotificationTableViewCell: UserViewDelegate { } diff --git a/TwidereSDK/Sources/TwidereUI/TableViewCell/Poll/PollOptionTableViewCell.swift b/TwidereSDK/Sources/TwidereUI/TableViewCell/Poll/PollOptionTableViewCell.swift deleted file mode 100644 index 9e4078d9..00000000 --- a/TwidereSDK/Sources/TwidereUI/TableViewCell/Poll/PollOptionTableViewCell.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// PollOptionTableViewCell.swift -// -// -// Created by MainasuK on 2021-12-8. -// - -import UIKit - -public final class PollOptionTableViewCell: UITableViewCell { - - public static let margin: CGFloat = 4 - public static let height: CGFloat = 2 * margin + PollOptionView.height - - public let optionView = PollOptionView() - - public override func prepareForReuse() { - super.prepareForReuse() - - optionView.disposeBag.removeAll() - optionView.prepareForReuse() - } - - public override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - _init() - } - - public required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } - - public override func setHighlighted(_ highlighted: Bool, animated: Bool) { - super.setHighlighted(highlighted, animated: animated) - - optionView.alpha = highlighted ? 0.5 : 1 - } - -} - -extension PollOptionTableViewCell { - - private func _init() { - selectionStyle = .none - backgroundColor = .clear - - optionView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(optionView) - NSLayoutConstraint.activate([ - optionView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: PollOptionTableViewCell.margin), - optionView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - optionView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - contentView.bottomAnchor.constraint(equalTo: optionView.bottomAnchor, constant: PollOptionTableViewCell.margin), - optionView.heightAnchor.constraint(equalToConstant: PollOptionView.height).priority(.required - 1), - ]) - optionView.setup(style: .plain) - - // accessibility - accessibilityElements = [optionView] - optionView.isAccessibilityElement = true - - } - -} diff --git a/TwidereSDK/Sources/TwidereUI/TableViewCell/Status/StatusTableViewCell.swift b/TwidereSDK/Sources/TwidereUI/TableViewCell/Status/StatusTableViewCell.swift new file mode 100644 index 00000000..3b4e7655 --- /dev/null +++ b/TwidereSDK/Sources/TwidereUI/TableViewCell/Status/StatusTableViewCell.swift @@ -0,0 +1,64 @@ +// +// StatusTableViewCell.swift +// StatusTableViewCell +// +// Created by Cirno MainasuK on 2021-8-20. +// Copyright © 2021 Twidere. All rights reserved. +// + +import os.log +import UIKit +import Combine + +public class StatusTableViewCell: UITableViewCell { + + let logger = Logger(subsystem: "StatusTableViewCell", category: "View") + + public weak var statusViewTableViewCellDelegate: StatusViewTableViewCellDelegate? + + @Published public private(set) var viewLayoutaFrame = ViewLayoutFrame() + + public override func prepareForReuse() { + super.prepareForReuse() + + contentConfiguration = nil + statusViewTableViewCellDelegate = nil + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + + public override func layoutSubviews() { + super.layoutSubviews() + + viewLayoutaFrame.update(view: self) + } + + public override func safeAreaInsetsDidChange() { + super.safeAreaInsetsDidChange() + + viewLayoutaFrame.update(view: self) + } + +} + +extension StatusTableViewCell { + + private func _init() { + selectionStyle = .none + } + +} + +// MARK: - StatusViewContainerTableViewCell +extension StatusTableViewCell: StatusViewContainerTableViewCell { } + +// MARK: - StatusViewDelegate +extension StatusTableViewCell: StatusViewDelegate { } diff --git a/TwidereSDK/Sources/TwidereUI/TableViewCell/Status/StatusViewTableViewCellDelegate.swift b/TwidereSDK/Sources/TwidereUI/TableViewCell/Status/StatusViewTableViewCellDelegate.swift new file mode 100644 index 00000000..dc336cfd --- /dev/null +++ b/TwidereSDK/Sources/TwidereUI/TableViewCell/Status/StatusViewTableViewCellDelegate.swift @@ -0,0 +1,94 @@ +// +// StatusViewTableViewCellDelegate.swift +// StatusViewTableViewCellDelegate +// +// Created by Cirno MainasuK on 2021-9-8. +// Copyright © 2021 Twidere. All rights reserved. +// + +import UIKit +import TwidereCore +import MetaTextArea +import Meta + +// sourcery: protocolName = "StatusViewDelegate" +// sourcery: replaceOf = "statusView(viewModel" +// sourcery: replaceWith = "statusViewTableViewCellDelegate?.tableViewCell(self, viewModel: viewModel" +public protocol StatusViewContainerTableViewCell: UITableViewCell, AutoGenerateProtocolRelayDelegate { + var statusViewTableViewCellDelegate: StatusViewTableViewCellDelegate? { get } +} + +// MARK: - AutoGenerateProtocolDelegate +// sourcery: protocolName = "StatusViewDelegate" +// sourcery: replaceOf = "statusView(_" +// sourcery: replaceWith = "func tableViewCell(_ cell: UITableViewCell," +public protocol StatusViewTableViewCellDelegate: AutoGenerateProtocolDelegate { + // sourcery:inline:StatusViewTableViewCellDelegate.AutoGenerateProtocolDelegate + func tableViewCell(_ cell: UITableViewCell, viewModel: StatusView.ViewModel, userAvatarButtonDidPressed user: UserRecord) + func tableViewCell(_ cell: UITableViewCell, viewModel: StatusView.ViewModel, toggleContentDisplay isReveal: Bool) + func tableViewCell(_ cell: UITableViewCell, viewModel: StatusView.ViewModel, textViewDidSelectMeta meta: Meta?) + func tableViewCell(_ cell: UITableViewCell, viewModel: StatusView.ViewModel, mediaViewModel: MediaView.ViewModel, action: MediaView.ViewModel.Action) + func tableViewCell(_ cell: UITableViewCell, viewModel: StatusView.ViewModel, toggleContentWarningOverlayDisplay isReveal: Bool) + 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) + // sourcery:end +} + +// MARK: - AutoGenerateProtocolDelegate +// Protocol Extension +public extension StatusViewDelegate where Self: StatusViewContainerTableViewCell { + // sourcery:inline:StatusViewContainerTableViewCell.AutoGenerateProtocolRelayDelegate + func statusView(_ viewModel: StatusView.ViewModel, userAvatarButtonDidPressed user: UserRecord) { + statusViewTableViewCellDelegate?.tableViewCell(self, viewModel: viewModel, userAvatarButtonDidPressed: user) + } + + func statusView(_ viewModel: StatusView.ViewModel, toggleContentDisplay isReveal: Bool) { + statusViewTableViewCellDelegate?.tableViewCell(self, viewModel: viewModel, toggleContentDisplay: isReveal) + } + + func statusView(_ viewModel: StatusView.ViewModel, textViewDidSelectMeta meta: Meta?) { + statusViewTableViewCellDelegate?.tableViewCell(self, viewModel: viewModel, textViewDidSelectMeta: meta) + } + + func statusView(_ viewModel: StatusView.ViewModel, mediaViewModel: MediaView.ViewModel, action: MediaView.ViewModel.Action) { + statusViewTableViewCellDelegate?.tableViewCell(self, viewModel: viewModel, mediaViewModel: mediaViewModel, action: action) + } + + func statusView(_ viewModel: StatusView.ViewModel, toggleContentWarningOverlayDisplay isReveal: Bool) { + statusViewTableViewCellDelegate?.tableViewCell(self, viewModel: viewModel, toggleContentWarningOverlayDisplay: isReveal) + } + + func statusView(_ viewModel: StatusView.ViewModel, pollVoteActionForViewModel pollViewModel: PollView.ViewModel) { + statusViewTableViewCellDelegate?.tableViewCell(self, viewModel: viewModel, pollVoteActionForViewModel: pollViewModel) + } + + func statusView(_ viewModel: StatusView.ViewModel, pollUpdateIfNeedsForViewModel pollViewModel: PollView.ViewModel) { + statusViewTableViewCellDelegate?.tableViewCell(self, viewModel: viewModel, pollUpdateIfNeedsForViewModel: pollViewModel) + } + + func statusView(_ viewModel: StatusView.ViewModel, pollViewModel: PollView.ViewModel, pollOptionDidSelectForViewModel optionViewModel: PollOptionView.ViewModel) { + 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) + } + + func statusView(_ viewModel: StatusView.ViewModel, statusToolbarViewModel: StatusToolbarView.ViewModel, statusToolbarButtonDidPressed action: StatusToolbarView.Action) { + statusViewTableViewCellDelegate?.tableViewCell(self, viewModel: viewModel, statusToolbarViewModel: statusToolbarViewModel, statusToolbarButtonDidPressed: action) + } + + func statusView(_ viewModel: StatusView.ViewModel, viewHeightDidChange: Void) { + statusViewTableViewCellDelegate?.tableViewCell(self, viewModel: viewModel, viewHeightDidChange: viewHeightDidChange) + } + // sourcery:end +} diff --git a/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserAccountStyleTableViewCell.swift b/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserAccountStyleTableViewCell.swift index f8b91a6c..b1599b4a 100644 --- a/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserAccountStyleTableViewCell.swift +++ b/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserAccountStyleTableViewCell.swift @@ -7,24 +7,24 @@ import UIKit -public final class UserAccountStyleTableViewCell: UserTableViewCell { - - let separator = SeparatorLineView() - - public override func _init() { - super._init() - - userView.setup(style: .account) - userView.viewModel.avatarBadge = .platform - - separator.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(separator) - NSLayoutConstraint.activate([ - separator.leadingAnchor.constraint(equalTo: userView.nameLabel.leadingAnchor), - separator.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), - separator.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - separator.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)), - ]) - } - -} +//public final class UserAccountStyleTableViewCell: UserTableViewCell { +// +// let separator = SeparatorLineView() +// +// public override func _init() { +// super._init() +// +// userView.setup(style: .account) +// userView.viewModel.avatarBadge = .platform +// +// separator.translatesAutoresizingMaskIntoConstraints = false +// contentView.addSubview(separator) +// NSLayoutConstraint.activate([ +// separator.leadingAnchor.constraint(equalTo: userView.nameLabel.leadingAnchor), +// separator.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), +// separator.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), +// separator.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)), +// ]) +// } +// +//} diff --git a/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserAddListMemberStyleTableViewCell.swift b/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserAddListMemberStyleTableViewCell.swift index 06c04b4c..03b4a4c7 100644 --- a/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserAddListMemberStyleTableViewCell.swift +++ b/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserAddListMemberStyleTableViewCell.swift @@ -7,23 +7,23 @@ import UIKit -public final class UserAddListMemberStyleTableViewCell: UserTableViewCell { - - let separator = SeparatorLineView() - - public override func _init() { - super._init() - - userView.setup(style: .addListMember) - - separator.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(separator) - NSLayoutConstraint.activate([ - separator.leadingAnchor.constraint(equalTo: userView.nameLabel.leadingAnchor), - separator.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), - separator.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - separator.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)), - ]) - } - -} +//public final class UserAddListMemberStyleTableViewCell: UserTableViewCell { +// +// let separator = SeparatorLineView() +// +// public override func _init() { +// super._init() +// +// userView.setup(style: .addListMember) +// +// separator.translatesAutoresizingMaskIntoConstraints = false +// contentView.addSubview(separator) +// NSLayoutConstraint.activate([ +// separator.leadingAnchor.constraint(equalTo: userView.nameLabel.leadingAnchor), +// separator.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), +// separator.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), +// separator.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)), +// ]) +// } +// +//} diff --git a/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserFriendshipStyleTableViewCell.swift b/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserFriendshipStyleTableViewCell.swift index 74a45a3a..0435e14a 100644 --- a/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserFriendshipStyleTableViewCell.swift +++ b/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserFriendshipStyleTableViewCell.swift @@ -7,23 +7,23 @@ import UIKit -public final class UserFriendshipStyleTableViewCell: UserTableViewCell { - - let separator = SeparatorLineView() - - public override func _init() { - super._init() - - userView.setup(style: .friendship) - - separator.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(separator) - NSLayoutConstraint.activate([ - separator.leadingAnchor.constraint(equalTo: userView.nameLabel.leadingAnchor), - separator.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), - separator.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - separator.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)), - ]) - } - -} +//public final class UserFriendshipStyleTableViewCell: UserTableViewCell { +// +// let separator = SeparatorLineView() +// +// public override func _init() { +// super._init() +// +// userView.setup(style: .friendship) +// +// separator.translatesAutoresizingMaskIntoConstraints = false +// contentView.addSubview(separator) +// NSLayoutConstraint.activate([ +// separator.leadingAnchor.constraint(equalTo: userView.nameLabel.leadingAnchor), +// separator.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), +// separator.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), +// separator.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)), +// ]) +// } +// +//} diff --git a/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserListMemberStyleTableViewCell.swift b/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserListMemberStyleTableViewCell.swift index 4e3a0ce0..4dfecc6d 100644 --- a/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserListMemberStyleTableViewCell.swift +++ b/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserListMemberStyleTableViewCell.swift @@ -7,23 +7,23 @@ import UIKit -public final class UserListMemberStyleTableViewCell: UserTableViewCell { - - let separator = SeparatorLineView() - - public override func _init() { - super._init() - - userView.setup(style: .listMember) - - separator.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(separator) - NSLayoutConstraint.activate([ - separator.leadingAnchor.constraint(equalTo: userView.nameLabel.leadingAnchor), - separator.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), - separator.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - separator.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)), - ]) - } - -} +//public final class UserListMemberStyleTableViewCell: UserTableViewCell { +// +// let separator = SeparatorLineView() +// +// public override func _init() { +// super._init() +// +// userView.setup(style: .listMember) +// +// separator.translatesAutoresizingMaskIntoConstraints = false +// contentView.addSubview(separator) +// NSLayoutConstraint.activate([ +// separator.leadingAnchor.constraint(equalTo: userView.nameLabel.leadingAnchor), +// separator.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), +// separator.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), +// separator.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)), +// ]) +// } +// +//} diff --git a/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserMentionPickStyleTableViewCell.swift b/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserMentionPickStyleTableViewCell.swift index f6823aed..179af1e2 100644 --- a/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserMentionPickStyleTableViewCell.swift +++ b/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserMentionPickStyleTableViewCell.swift @@ -7,13 +7,13 @@ import UIKit -public final class UserMentionPickStyleTableViewCell: UserTableViewCell { - - - public override func _init() { - super._init() - - userView.setup(style: .mentionPick) - } - -} +//public final class UserMentionPickStyleTableViewCell: UserTableViewCell { +// +// +// public override func _init() { +// super._init() +// +// userView.setup(style: .mentionPick) +// } +// +//} diff --git a/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserNotificationStyleTableViewCell.swift b/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserNotificationStyleTableViewCell.swift index 0c551382..96556594 100644 --- a/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserNotificationStyleTableViewCell.swift +++ b/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserNotificationStyleTableViewCell.swift @@ -8,23 +8,23 @@ import UIKit -public final class UserNotificationStyleTableViewCell: UserTableViewCell { - - let separator = SeparatorLineView() - - public override func _init() { - super._init() - - userView.setup(style: .notification) - - separator.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(separator) - NSLayoutConstraint.activate([ - separator.leadingAnchor.constraint(equalTo: userView.nameLabel.leadingAnchor), - separator.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), - separator.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - separator.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)), - ]) - } - -} +//public final class UserNotificationStyleTableViewCell: UserTableViewCell { +// +// let separator = SeparatorLineView() +// +// public override func _init() { +// super._init() +// +// userView.setup(style: .notification) +// +// separator.translatesAutoresizingMaskIntoConstraints = false +// contentView.addSubview(separator) +// NSLayoutConstraint.activate([ +// separator.leadingAnchor.constraint(equalTo: userView.nameLabel.leadingAnchor), +// separator.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), +// separator.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), +// separator.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)), +// ]) +// } +// +//} diff --git a/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserRelationshipStyleTableViewCell.swift b/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserRelationshipStyleTableViewCell.swift index dab0dfa8..18ffdaea 100644 --- a/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserRelationshipStyleTableViewCell.swift +++ b/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserRelationshipStyleTableViewCell.swift @@ -8,23 +8,23 @@ import UIKit -public final class UserRelationshipStyleTableViewCell: UserTableViewCell { - - let separator = SeparatorLineView() - - public override func _init() { - super._init() - - userView.setup(style: .relationship) - - separator.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(separator) - NSLayoutConstraint.activate([ - separator.leadingAnchor.constraint(equalTo: userView.nameLabel.leadingAnchor), - separator.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), - separator.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - separator.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)), - ]) - } - -} +//public final class UserRelationshipStyleTableViewCell: UserTableViewCell { +// +// let separator = SeparatorLineView() +// +// public override func _init() { +// super._init() +// +// userView.setup(style: .relationship) +// +// separator.translatesAutoresizingMaskIntoConstraints = false +// contentView.addSubview(separator) +// NSLayoutConstraint.activate([ +// separator.leadingAnchor.constraint(equalTo: userView.nameLabel.leadingAnchor), +// separator.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), +// separator.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), +// separator.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)), +// ]) +// } +// +//} diff --git a/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserTableViewCell+ViewModel.swift b/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserTableViewCell+ViewModel.swift index 4d7a0dc1..72120058 100644 --- a/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserTableViewCell+ViewModel.swift +++ b/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserTableViewCell+ViewModel.swift @@ -11,36 +11,36 @@ import CoreDataStack import TwidereCore extension UserTableViewCell { - public final class ViewModel { - public let user: UserObject - public let me: UserObject? - public let notification: NotificationObject? - - public init( - user: UserObject, - me: UserObject?, - notification: NotificationObject? - ) { - self.user = user - self.me = me - self.notification = notification - } - } +// public final class ViewModel { +// public let user: UserObject +// public let me: UserObject? +// public let notification: NotificationObject? +// +// public init( +// user: UserObject, +// me: UserObject?, +// notification: NotificationObject? +// ) { +// self.user = user +// self.me = me +// self.notification = notification +// } +// } } extension UserTableViewCell { - public func configure( - viewModel: ViewModel, - configurationContext: UserView.ConfigurationContext, - delegate: UserViewTableViewCellDelegate? - ) { - userView.configure( - user: viewModel.user, - me: viewModel.me, - notification: viewModel.notification, - configurationContext: configurationContext - ) - - self.delegate = delegate - } +// public func configure( +// viewModel: ViewModel, +// configurationContext: UserView.ConfigurationContext, +// delegate: UserViewTableViewCellDelegate? +// ) { +// userView.configure( +// user: viewModel.user, +// me: viewModel.me, +// notification: viewModel.notification, +// configurationContext: configurationContext +// ) +// +// self.delegate = delegate +// } } diff --git a/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserTableViewCell.swift b/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserTableViewCell.swift index 3021a8c8..83aeff8e 100644 --- a/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserTableViewCell.swift +++ b/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserTableViewCell.swift @@ -11,21 +11,16 @@ import UIKit import Combine public class UserTableViewCell: UITableViewCell { - - var disposeBag = Set() - - let logger = Logger(subsystem: "UserTableViewCell", category: "View") - public let userView = UserView() - - public weak var delegate: UserViewTableViewCellDelegate? - + let logger = Logger(subsystem: "UserTableViewCell", category: "View") + + public weak var userViewTableViewCellDelegate: UserViewTableViewCellDelegate? + public override func prepareForReuse() { super.prepareForReuse() - userView.prepareForReuse() - disposeBag.removeAll() - delegate = nil + contentConfiguration = nil + userViewTableViewCellDelegate = nil } public override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { @@ -39,16 +34,7 @@ public class UserTableViewCell: UITableViewCell { } func _init() { - userView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(userView) - NSLayoutConstraint.activate([ - userView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20), - userView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), - userView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), - contentView.bottomAnchor.constraint(equalTo: userView.bottomAnchor, constant: 16).priority(.defaultHigh), - ]) - - userView.delegate = self + // selectionStyle = .none } } @@ -56,5 +42,5 @@ public class UserTableViewCell: UITableViewCell { // MARK: - UserViewContainerTableViewCell extension UserTableViewCell: UserViewContainerTableViewCell { } -// MARK: - +// MARK: - UserViewDelegate extension UserTableViewCell: UserViewDelegate { } diff --git a/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserViewTableViewCellDelegate.swift b/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserViewTableViewCellDelegate.swift index a014de1a..1a9a56df 100644 --- a/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserViewTableViewCellDelegate.swift +++ b/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserViewTableViewCellDelegate.swift @@ -6,14 +6,12 @@ // import UIKit -import TwidereCommon // sourcery: protocolName = "UserViewDelegate" -// sourcery: replaceOf = "userView(userView" -// sourcery: replaceWith = "delegate?.tableViewCell(self, userView: userView" +// sourcery: replaceOf = "userView(viewModel" +// sourcery: replaceWith = "userViewTableViewCellDelegate?.tableViewCell(self, viewModel: viewModel" public protocol UserViewContainerTableViewCell: UITableViewCell, AutoGenerateProtocolRelayDelegate { - var delegate: UserViewTableViewCellDelegate? { get } - var userView: UserView { get } + var userViewTableViewCellDelegate: UserViewTableViewCellDelegate? { get } } @@ -23,36 +21,31 @@ public protocol UserViewContainerTableViewCell: UITableViewCell, AutoGeneratePro // sourcery: replaceWith = "func tableViewCell(_ cell: UITableViewCell," public protocol UserViewTableViewCellDelegate: AutoGenerateProtocolDelegate { // sourcery:inline:UserViewTableViewCellDelegate.AutoGenerateProtocolDelegate - func tableViewCell(_ cell: UITableViewCell, userView: UserView, menuActionDidPressed action: UserView.MenuAction, menuButton button: UIButton) - func tableViewCell(_ cell: UITableViewCell, userView: UserView, friendshipButtonDidPressed button: UIButton) - func tableViewCell(_ cell: UITableViewCell, userView: UserView, membershipButtonDidPressed button: UIButton) - func tableViewCell(_ cell: UITableViewCell, userView: UserView, acceptFollowReqeustButtonDidPressed button: UIButton) - func tableViewCell(_ cell: UITableViewCell, userView: UserView, rejectFollowReqeustButtonDidPressed button: UIButton) + 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 } // MARK: - AutoGenerateProtocolDelegate // Protocol Extension -extension UserViewDelegate where Self: UserViewContainerTableViewCell { +public extension UserViewDelegate where Self: UserViewContainerTableViewCell { // sourcery:inline:UserViewContainerTableViewCell.AutoGenerateProtocolRelayDelegate - func userView(_ userView: UserView, menuActionDidPressed action: UserView.MenuAction, menuButton button: UIButton) { - delegate?.tableViewCell(self, userView: userView, menuActionDidPressed: action, menuButton: button) + func userView(_ viewModel: UserView.ViewModel, userAvatarButtonDidPressed user: UserRecord) { + userViewTableViewCellDelegate?.tableViewCell(self, viewModel: viewModel, userAvatarButtonDidPressed: user) } - func userView(_ userView: UserView, friendshipButtonDidPressed button: UIButton) { - delegate?.tableViewCell(self, userView: userView, friendshipButtonDidPressed: button) + func userView(_ viewModel: UserView.ViewModel, menuActionDidPressed action: UserView.ViewModel.MenuAction) { + userViewTableViewCellDelegate?.tableViewCell(self, viewModel: viewModel, menuActionDidPressed: action) } - func userView(_ userView: UserView, membershipButtonDidPressed button: UIButton) { - delegate?.tableViewCell(self, userView: userView, membershipButtonDidPressed: button) + func userView(_ viewModel: UserView.ViewModel, listMembershipButtonDidPressed user: UserRecord) { + userViewTableViewCellDelegate?.tableViewCell(self, viewModel: viewModel, listMembershipButtonDidPressed: user) } - func userView(_ userView: UserView, acceptFollowReqeustButtonDidPressed button: UIButton) { - delegate?.tableViewCell(self, userView: userView, acceptFollowReqeustButtonDidPressed: button) - } - - func userView(_ userView: UserView, rejectFollowReqeustButtonDidPressed button: UIButton) { - delegate?.tableViewCell(self, userView: userView, rejectFollowReqeustButtonDidPressed: button) + func userView(_ viewModel: UserView.ViewModel, followReqeustButtonDidPressed user: UserRecord, accept: Bool) { + userViewTableViewCellDelegate?.tableViewCell(self, viewModel: viewModel, followReqeustButtonDidPressed: user, accept: accept) } // sourcery:end diff --git a/TwidereSDK/Sources/TwidereUI/Toolbar/StatusToolbar+ViewModel.swift b/TwidereSDK/Sources/TwidereUI/Toolbar/StatusToolbar+ViewModel.swift deleted file mode 100644 index 28a93097..00000000 --- a/TwidereSDK/Sources/TwidereUI/Toolbar/StatusToolbar+ViewModel.swift +++ /dev/null @@ -1,292 +0,0 @@ -// -// StatusToolbar+ViewModel.swift -// -// -// Created by MainasuK on 2022-2-23. -// - -import UIKit -import Combine -import CoreDataStack -import TwidereAsset - -extension StatusToolbar { - public final class ViewModel: ObservableObject { - var disposeBag = Set() - - @Published public var traitCollectionDidChange = CurrentValueSubject(Void()) - @Published public var platform: Platform = .none - - @Published public var replyCount: Int = 0 - @Published public var isReplyEnabled = true - - @Published public var repostCount: Int = 0 - @Published public var isRepostEnabled = true - @Published public var isRepostHighlighted = true - - @Published public var likeCount: Int = 0 - // @Published public var isLikeEnabled = true - @Published public var isLikeHighlighted = true - - func bind(view: StatusToolbar) { - // reply - Publishers.CombineLatest( - $replyCount, - $isReplyEnabled - ) - .sink { count, isEnabled in - // title - let text = ViewModel.metricText(count: count) - switch view.style { - case .none: - break - case .inline: - view.replyButton.setTitle(text, for: .normal) - view.replyButton.accessibilityHint = L10n.Count.reply(count) - case .plain: - view.replyButton.accessibilityHint = nil - } - view.replyButton.accessibilityLabel = L10n.Accessibility.Common.Status.Actions.reply - - // isEnabled - view.replyButton.isEnabled = isEnabled - } - .store(in: &disposeBag) - // repost - Publishers.CombineLatest3( - $repostCount, - $isRepostEnabled, - $platform - ) - .sink { count, isEnabled, platform in - // title - let text = ViewModel.metricText(count: count) - switch view.style { - case .none: - break - case .inline: - view.repostButton.setTitle(text, for: .normal) - view.repostButton.accessibilityHint = L10n.Count.reblog(count) - case .plain: - view.repostButton.accessibilityHint = nil - } - - switch platform { - case .none: - view.repostButton.accessibilityLabel = nil - case .twitter: - view.repostButton.accessibilityLabel = L10n.Common.Controls.Status.Actions.retweet - case .mastodon: - view.repostButton.accessibilityLabel = L10n.Common.Controls.Status.Actions.boost - } - - // isEnabled - view.repostButton.isEnabled = isEnabled - } - .store(in: &disposeBag) - Publishers.CombineLatest( - $isRepostHighlighted, - $traitCollectionDidChange - ) - .sink { isHighlighted, _ in - // isHighlighted - let tintColor = isHighlighted ? Asset.Scene.Status.Toolbar.repost.color : .secondaryLabel - view.repostButton.tintColor = tintColor - view.repostButton.setTitleColor(tintColor, for: .normal) - view.repostButton.setTitleColor(tintColor.withAlphaComponent(0.8), for: .highlighted) - if isHighlighted { - view.repostButton.accessibilityTraits.insert(.selected) - } else { - view.repostButton.accessibilityTraits.remove(.selected) - } - } - .store(in: &disposeBag) - // like - $likeCount - .sink { count in - // title - let text = ViewModel.metricText(count: count) - switch view.style { - case .none: - break - case .inline: - view.likeButton.setTitle(text, for: .normal) - view.likeButton.accessibilityHint = L10n.Count.reply(count) - case .plain: - view.likeButton.accessibilityHint = nil - // no titile - } - view.likeButton.accessibilityLabel = L10n.Accessibility.Common.Status.Actions.like - } - .store(in: &disposeBag) - Publishers.CombineLatest( - $isLikeHighlighted, - $traitCollectionDidChange - ) - .sink { isHighlighted, _ in - // isHighlighted - let tintColor = isHighlighted ? Asset.Scene.Status.Toolbar.like.color : .secondaryLabel - view.likeButton.tintColor = tintColor - view.likeButton.setTitleColor(tintColor, for: .normal) - view.likeButton.setTitleColor(tintColor.withAlphaComponent(0.8), for: .highlighted) - switch view.style { - case .none: - break - case .inline: - let image: UIImage = isHighlighted ? Asset.Health.heartFillMini.image : Asset.Health.heartMini.image - view.likeButton.setImage(image.withRenderingMode(.alwaysTemplate), for: .normal) - case .plain: - let image: UIImage = isHighlighted ? Asset.Health.heartFill.image : Asset.Health.heart.image - view.likeButton.setImage(image.withRenderingMode(.alwaysTemplate), for: .normal) - } - if isHighlighted { - view.likeButton.accessibilityTraits.insert(.selected) - } else { - view.likeButton.accessibilityTraits.remove(.selected) - } - } - .store(in: &disposeBag) - } - - private static func metricText(count: Int) -> String { - guard count > 0 else { return "" } - return StatusToolbar.numberMetricFormatter.string(from: count) ?? "" - } - } -} - -extension StatusToolbar { - - public func setupReply(count: Int, isEnabled: Bool) { - viewModel.replyCount = count - viewModel.isReplyEnabled = isEnabled - } - - public func setupRepost(count: Int, isEnabled: Bool, isHighlighted: Bool) { - viewModel.repostCount = count - viewModel.isRepostEnabled = isEnabled - viewModel.isRepostHighlighted = isHighlighted - } - - public func setupLike(count: Int, isHighlighted: Bool) { - viewModel.likeCount = count - viewModel.isLikeHighlighted = isHighlighted - } - - public struct MenuContext { - let shareText: String? - let shareLink: String? - let displaySaveMediaAction: Bool - let displayDeleteAction: Bool - } - - public func setupMenu(menuContext: MenuContext) { - menuButton.menu = { - var children: [UIMenuElement] = [] - - let copyTextAction = UIAction( - title: L10n.Common.Controls.Status.Actions.copyText.capitalized, - image: UIImage(systemName: "doc.on.doc"), - identifier: nil, - discoverabilityTitle: nil, - attributes: [], - state: .off - ) { _ in - guard let text = menuContext.shareText else { return } - UIPasteboard.general.string = text - } - children.append(copyTextAction) - - let copyLink = UIAction( - title: L10n.Common.Controls.Status.Actions.copyLink.capitalized, - image: UIImage(systemName: "link"), - identifier: nil, - discoverabilityTitle: nil, - attributes: [], - state: .off - ) { _ in - guard let text = menuContext.shareLink else { return } - UIPasteboard.general.string = text - } - children.append(copyLink) - - let shareLink = UIAction( - title: L10n.Common.Controls.Status.Actions.shareLink.capitalized, - image: UIImage(systemName: "square.and.arrow.up"), - identifier: nil, - discoverabilityTitle: nil, - attributes: [], - state: .off - ) { [weak self] _ in - guard let self = self else { return } - self.delegate?.statusToolbar(self, menuActionDidPressed: .share, menuButton: self.menuButton) - } - children.append(shareLink) - - if menuContext.displaySaveMediaAction { - let saveMdeiaAction = UIAction( - title: L10n.Common.Controls.Status.Actions.saveMedia.capitalized, - image: UIImage(systemName: "square.and.arrow.down"), - identifier: nil, - discoverabilityTitle: nil, - attributes: [], - state: .off - ) { [weak self] _ in - guard let self = self else { return } - self.delegate?.statusToolbar(self, menuActionDidPressed: .saveMedia, menuButton: self.menuButton) - } - children.append(saveMdeiaAction) - } - - let translateAction = UIAction( - title: L10n.Common.Controls.Status.Actions.translate.capitalized, - image: UIImage(systemName: "character.bubble"), - identifier: nil, - discoverabilityTitle: nil, - attributes: [], - state: .off - ) { [weak self] _ in - guard let self = self else { return } - self.delegate?.statusToolbar(self, menuActionDidPressed: .translate, menuButton: self.menuButton) - } - children.append(translateAction) - - if menuContext.displayDeleteAction { - let removeAction = UIAction( - title: L10n.Common.Controls.Actions.delete, - image: UIImage(systemName: "minus.circle"), - identifier: nil, - discoverabilityTitle: nil, - attributes: .destructive, - state: .off - ) { [weak self] _ in - guard let self = self else { return } - self.delegate?.statusToolbar(self, menuActionDidPressed: .remove, menuButton: self.menuButton) - } - children.append(removeAction) - } - - #if DEBUG - let copyIDAction = UIAction( - title: "Copy ID", - image: UIImage(systemName: "number.square"), - identifier: nil, - discoverabilityTitle: nil, - attributes: [], - state: .off - ) { [weak self] _ in - guard let self = self else { return } - self.delegate?.statusToolbar(self, menuActionDidPressed: .copyID, menuButton: self.menuButton) - } - let debugMenu = UIMenu(title: "", options: .displayInline, children: [copyIDAction]) - children.append(debugMenu) - #endif - - return UIMenu(title: "", options: [], children: children) - }() - - menuButton.showsMenuAsPrimaryAction = true - menuButton.accessibilityLabel = L10n.Accessibility.Common.Status.Actions.menu - } - -} diff --git a/TwidereSDK/Sources/TwidereUI/Toolbar/StatusToolbar.swift b/TwidereSDK/Sources/TwidereUI/Toolbar/StatusToolbar.swift deleted file mode 100644 index 6f375cc7..00000000 --- a/TwidereSDK/Sources/TwidereUI/Toolbar/StatusToolbar.swift +++ /dev/null @@ -1,211 +0,0 @@ -// -// StatusToolbar.swift -// StatusToolbar -// -// Created by Cirno MainasuK on 2021-8-23. -// Copyright © 2021 Twidere. All rights reserved. -// - -import os.log -import UIKit -import TwidereCore - -public protocol StatusToolbarDelegate: AnyObject { - func statusToolbar(_ statusToolbar: StatusToolbar, actionDidPressed action: StatusToolbar.Action, button: UIButton) - func statusToolbar(_ statusToolbar: StatusToolbar, menuActionDidPressed action: StatusToolbar.MenuAction, menuButton button: UIButton) -} - -public final class StatusToolbar: UIView { - - public static let numberMetricFormatter = NumberMetricFormatter() - - public weak var delegate: StatusToolbarDelegate? - - public private(set) lazy var viewModel: ViewModel = { - let viewModel = ViewModel() - viewModel.bind(view: self) - return viewModel - }() - - private let logger = Logger(subsystem: "StatusToolbar", category: "Toolbar") - private let container = UIStackView() - private(set) var style: Style? - - public let replyButton = HitTestExpandedButton() - public let repostButton = HitTestExpandedButton() - public let likeButton = HitTestExpandedButton() - public let menuButton = HitTestExpandedButton() - - - public override init(frame: CGRect) { - super.init(frame: frame) - _init() - } - - public required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } - - public override func willMove(toWindow newWindow: UIWindow?) { - super.willMove(toWindow: newWindow) - assert(style != nil, "Needs setup style before use") - } - -} - -extension StatusToolbar { - - private func _init() { - container.translatesAutoresizingMaskIntoConstraints = false - addSubview(container) - NSLayoutConstraint.activate([ - container.topAnchor.constraint(equalTo: topAnchor), - container.leadingAnchor.constraint(equalTo: leadingAnchor), - container.trailingAnchor.constraint(equalTo: trailingAnchor), - container.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - - replyButton.accessibilityLabel = L10n.Accessibility.Common.Status.Actions.reply - // dynamic label for repostButton - likeButton.accessibilityLabel = L10n.Accessibility.Common.Status.Actions.like - menuButton.accessibilityLabel = L10n.Accessibility.Common.Status.Actions.menu - } - - public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - viewModel.traitCollectionDidChange.send() - } - - public func setup(style: Style) { - self.style = style - - container.arrangedSubviews.forEach { subview in - container.removeArrangedSubview(subview) - subview.removeFromSuperview() - } - - let buttons = [replyButton, repostButton, likeButton, menuButton] - buttons.forEach { button in - button.tintColor = .secondaryLabel - button.titleLabel?.font = .monospacedDigitSystemFont(ofSize: 12, weight: .regular) - button.setTitle("", for: .normal) - button.setTitleColor(.secondaryLabel, for: .normal) - button.setInsets(forContentPadding: .zero, imageTitlePadding: style.buttonTitleImagePadding) - button.addTarget(self, action: #selector(StatusToolbar.buttonDidPressed(_:)), for: .touchUpInside) - } - - switch style { - case .inline: - buttons.forEach { button in - button.contentHorizontalAlignment = .leading - } - replyButton.setImage(Asset.Arrows.arrowTurnUpLeftMini.image.withRenderingMode(.alwaysTemplate), for: .normal) - repostButton.setImage(Asset.Media.repeatMini.image.withRenderingMode(.alwaysTemplate), for: .normal) - likeButton.setImage(Asset.Health.heartMini.image.withRenderingMode(.alwaysTemplate), for: .normal) - menuButton.setImage(Asset.Editing.ellipsisMini.image.withRenderingMode(.alwaysTemplate), for: .normal) - - container.axis = .horizontal - container.distribution = .fill - - replyButton.translatesAutoresizingMaskIntoConstraints = false - repostButton.translatesAutoresizingMaskIntoConstraints = false - likeButton.translatesAutoresizingMaskIntoConstraints = false - menuButton.translatesAutoresizingMaskIntoConstraints = false - container.addArrangedSubview(replyButton) - container.addArrangedSubview(repostButton) - container.addArrangedSubview(likeButton) - container.addArrangedSubview(menuButton) - NSLayoutConstraint.activate([ - replyButton.heightAnchor.constraint(equalToConstant: 40).priority(.required - 10), - replyButton.heightAnchor.constraint(equalTo: repostButton.heightAnchor).priority(.defaultHigh), - replyButton.heightAnchor.constraint(equalTo: likeButton.heightAnchor).priority(.defaultHigh), - replyButton.heightAnchor.constraint(equalTo: menuButton.heightAnchor).priority(.defaultHigh), - replyButton.widthAnchor.constraint(equalTo: repostButton.widthAnchor).priority(.defaultHigh), - replyButton.widthAnchor.constraint(equalTo: likeButton.widthAnchor).priority(.defaultHigh), - ]) - menuButton.setContentHuggingPriority(.defaultHigh, for: .horizontal) - menuButton.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) - - case .plain: - buttons.forEach { button in - button.contentHorizontalAlignment = .center - } - replyButton.setImage(Asset.Arrows.arrowTurnUpLeft.image.withRenderingMode(.alwaysTemplate), for: .normal) - repostButton.setImage(Asset.Media.repeat.image.withRenderingMode(.alwaysTemplate), for: .normal) - likeButton.setImage(Asset.Health.heart.image.withRenderingMode(.alwaysTemplate), for: .normal) - menuButton.setImage(Asset.Editing.ellipsis.image.withRenderingMode(.alwaysTemplate), for: .normal) - - container.axis = .horizontal - container.spacing = 8 - container.distribution = .fillEqually - - container.addArrangedSubview(replyButton) - container.addArrangedSubview(repostButton) - container.addArrangedSubview(likeButton) - container.addArrangedSubview(menuButton) - - NSLayoutConstraint.activate([ - replyButton.heightAnchor.constraint(equalToConstant: 47).priority(.required - 10), - replyButton.heightAnchor.constraint(equalTo: repostButton.heightAnchor).priority(.defaultHigh), - replyButton.heightAnchor.constraint(equalTo: likeButton.heightAnchor).priority(.defaultHigh), - replyButton.heightAnchor.constraint(equalTo: menuButton.heightAnchor).priority(.defaultHigh), - ]) - } - } -} - -extension StatusToolbar { - public enum Action: String, CaseIterable { - case reply - case repost - case like - case menu - } - - public enum MenuAction: String, CaseIterable { - case saveMedia - case translate - case share - case remove - #if DEBUG - case copyID - #endif - } - - public enum Style { - case inline - case plain - - var buttonTitleImagePadding: CGFloat { - switch self { - case .inline: return 4.0 - case .plain: return 0 - } - } - } -} - -extension StatusToolbar { - - @objc private func buttonDidPressed(_ sender: UIButton) { - let _action: Action? - switch sender { - case replyButton: _action = .reply - case repostButton: _action = .repost - case likeButton: _action = .like - case menuButton: _action = .menu - default: _action = nil - } - - guard let action = _action else { - assertionFailure() - return - } - - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(action.rawValue) button pressed") - delegate?.statusToolbar(self, actionDidPressed: action, button: sender) - } - -} diff --git a/TwidereSDK/Sources/TwidereUI/UIViewRepresentable/ContextMenuInteractionRepresentable.swift b/TwidereSDK/Sources/TwidereUI/UIViewRepresentable/ContextMenuInteractionRepresentable.swift new file mode 100644 index 00000000..5dfb49ca --- /dev/null +++ b/TwidereSDK/Sources/TwidereUI/UIViewRepresentable/ContextMenuInteractionRepresentable.swift @@ -0,0 +1,117 @@ +// +// ContextMenuInteractionRepresentable.swift +// +// +// Created by MainasuK on 2023/2/27. +// + +import os.log +import UIKit +import SwiftUI +import Combine + +struct ContextMenuInteractionRepresentable: UIViewRepresentable { + + let contextMenuContentPreviewProvider: UIContextMenuContentPreviewProvider + let contextMenuActionProvider: UIContextMenuActionProvider + @ViewBuilder var view: Content + let previewActionWithContext: (ContextMenuInteractionPreviewActionContext) -> Void + + func makeUIView(context: Context) -> UIView { + let hostingController = UIHostingController(rootView: view) + hostingController.view.backgroundColor = .clear + context.coordinator.hostingViewController = hostingController + let interaction = UIContextMenuInteraction(delegate: context.coordinator) + hostingController.view.addInteraction(interaction) + hostingController.view.setContentHuggingPriority(.defaultHigh, for: .vertical) + return hostingController.view + } + + func updateUIView(_ view: UIView, context: Context) { + // do nothing + } + + func makeCoordinator() -> Coordinator { + return Coordinator(representable: self) + } + + class Coordinator: NSObject, UIContextMenuInteractionDelegate { + let logger = Logger(subsystem: "ContextMenuInteractionRepresentable", category: "Coordinator") + + var disposeBag = Set() + + let representable: ContextMenuInteractionRepresentable + + var hostingViewController: UIHostingController? + + var activePreviewActionContext: ContextMenuInteractionPreviewActionContext? + + @Published var previewViewFrameInWindow: CGRect = .zero + + init(representable: ContextMenuInteractionRepresentable) { + self.representable = representable + } + + func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { + UIContextMenuConfiguration(identifier: nil, previewProvider: representable.contextMenuContentPreviewProvider, actionProvider: representable.contextMenuActionProvider) + } + + func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configuration: UIContextMenuConfiguration, highlightPreviewForItemWithIdentifier identifier: NSCopying) -> UITargetedPreview? { + guard let hostingViewController = self.hostingViewController else { return nil } + let parameters = UIPreviewParameters() + parameters.backgroundColor = .clear + parameters.visiblePath = UIBezierPath(roundedRect: hostingViewController.view.bounds, cornerRadius: MediaGridContainerView.cornerRadius) + let targetedPreview = UITargetedPreview(view: hostingViewController.view, parameters: parameters) + return targetedPreview + } + + func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configuration: UIContextMenuConfiguration, dismissalPreviewForItemWithIdentifier identifier: NSCopying) -> UITargetedPreview? { + return activePreviewActionContext?.dismissTargetedPreviewHandler() + } + + func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + let context = ContextMenuInteractionPreviewActionContext( + interaction: interaction, + animator: animator + ) + activePreviewActionContext = context + representable.previewActionWithContext(context) + } + + func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willDisplayMenuFor configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) { + print(#function) + } + + func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willEndFor configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) { + print(#function) + } + } +} + +public class ContextMenuInteractionPreviewActionContext { + public let interaction: UIContextMenuInteraction + public let animator: UIContextMenuInteractionCommitAnimating + public var dismissTargetedPreviewHandler: () -> UITargetedPreview? = { nil } + + public init(interaction: UIContextMenuInteraction, animator: UIContextMenuInteractionCommitAnimating) { + self.interaction = interaction + self.animator = animator + } +} + +extension ContextMenuInteractionPreviewActionContext { + public func platterClippingView() -> UIView? { + // iOS 16: pass + guard let window = interaction.view?.window, + let contextMenuContainerView = window.subviews.first(where: { !($0.gestureRecognizers ?? []).isEmpty }), + let contextMenuPlatterTransitionView = contextMenuContainerView.subviews.first(where: { !($0 is UIVisualEffectView) }), + let morphingPlatterView = contextMenuPlatterTransitionView.subviews.first(where: { ($0.gestureRecognizers ?? []).count == 1 }), + let platterClippingView = morphingPlatterView.subviews.last, platterClippingView.bounds != .zero + else { + assertionFailure("system API changes!") + return nil + } + + return platterClippingView + } +} diff --git a/TwidereSDK/Sources/TwidereUI/UIViewRepresentable/GIFVideoPlayerRepresentable.swift b/TwidereSDK/Sources/TwidereUI/UIViewRepresentable/GIFVideoPlayerRepresentable.swift new file mode 100644 index 00000000..c59eb607 --- /dev/null +++ b/TwidereSDK/Sources/TwidereUI/UIViewRepresentable/GIFVideoPlayerRepresentable.swift @@ -0,0 +1,98 @@ +// +// GIFVideoPlayerRepresentable.swift +// +// +// Created by MainasuK on 2023/2/28. +// + +import os.log +import UIKit +import SwiftUI +import Combine +import AVKit +import AVFoundation + +public struct GIFVideoPlayerRepresentable: UIViewRepresentable { + + let controller = AVPlayerViewController() + + // input + let assetURL: URL + + // output + + public func makeUIView(context: Context) -> UIView { + let playerItem = AVPlayerItem(url: assetURL) + let player = AVQueuePlayer(playerItem: playerItem) + player.isMuted = true + context.coordinator.player = player + let playerLooper = AVPlayerLooper(player: player, templateItem: playerItem) + context.coordinator.playerLooper = playerLooper + + controller.player = player + controller.showsPlaybackControls = false + + controller.view.alpha = 0 + context.coordinator.setupPlayer() + + return controller.view + } + + public func updateUIView(_ view: UIView, context: Context) { + // do nothing + } + + public func makeCoordinator() -> Coordinator { + return Coordinator(self) + } + + public class Coordinator { + var disposeBag = Set() + + let representable: GIFVideoPlayerRepresentable + + var player: AVPlayer? + var playerLooper: AVPlayerLooper? + + init(_ representable: GIFVideoPlayerRepresentable) { + self.representable = representable + } + + func setupPlayer() { + guard let player = self.player, + let playerItem = player.currentItem + else { + assertionFailure() + return + } + + playerItem.publisher(for: \.status) + .receive(on: DispatchQueue.main) + .sink { [weak self] status in + guard let self = self else { return } + switch status { + case .readyToPlay: + self.representable.controller.view.alpha = 1 + self.player?.play() + default: + break + } + } + .store(in: &disposeBag) + } // end func + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + disposeBag.removeAll() + + player?.pause() + player = nil + playerLooper?.disableLooping() + playerLooper = nil + representable.controller.player = nil + representable.controller.removeFromParent() + representable.controller.view.removeFromSuperview() + } + } // end Coordinator + +} diff --git a/TwidereSDK/Sources/TwidereUI/UIViewRepresentable/LabelRepresentable.swift b/TwidereSDK/Sources/TwidereUI/UIViewRepresentable/LabelRepresentable.swift new file mode 100644 index 00000000..f135680c --- /dev/null +++ b/TwidereSDK/Sources/TwidereUI/UIViewRepresentable/LabelRepresentable.swift @@ -0,0 +1,62 @@ +// +// LabelRepresentable.swift +// +// +// Created by MainasuK on 2023/2/6. +// + +import UIKit +import SwiftUI +import TwidereCore +import MetaTextKit +import MetaLabel + +public struct LabelRepresentable: UIViewRepresentable { + + let label: MetaLabel + + // input + let metaContent: MetaContent + let textStyle: TextStyle + let setupLabel: (MetaLabel) -> Void + + public init( + metaContent: MetaContent, + textStyle: TextStyle, + setupLabel: @escaping (MetaLabel) -> Void + ) { + self.metaContent = metaContent + self.textStyle = textStyle + self.setupLabel = setupLabel + self.label = { + let label = MetaLabel(style: textStyle) + label.textArea.textContainer.lineBreakMode = .byTruncatingTail + label.setContentHuggingPriority(.defaultHigh, for: .horizontal) // always try grow vertical + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + return label + }() + } + + public func makeUIView(context: Context) -> UIView { + setupLabel(label) + label.configure(content: metaContent) + return label + } + + public func updateUIView(_ view: UIView, context: Context) { + // do nothing + } + + public func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + public class Coordinator: NSObject, UITextViewDelegate { + let view: LabelRepresentable + + init(_ view: LabelRepresentable) { + self.view = view + super.init() + } + } +} diff --git a/TwidereSDK/Sources/TwidereUI/UIViewRepresentable/MetaLabelRepresentable.swift b/TwidereSDK/Sources/TwidereUI/UIViewRepresentable/MetaLabelRepresentable.swift index babf6ff1..bbaf148c 100644 --- a/TwidereSDK/Sources/TwidereUI/UIViewRepresentable/MetaLabelRepresentable.swift +++ b/TwidereSDK/Sources/TwidereUI/UIViewRepresentable/MetaLabelRepresentable.swift @@ -9,6 +9,7 @@ import UIKit import SwiftUI import TwidereCore import MetaTextKit +import MetaLabel public struct MetaLabelRepresentable: UIViewRepresentable { diff --git a/TwidereSDK/Sources/TwidereUI/UIViewRepresentable/PrototypeStatusViewRepresentable.swift b/TwidereSDK/Sources/TwidereUI/UIViewRepresentable/PrototypeStatusViewRepresentable.swift new file mode 100644 index 00000000..55c68aee --- /dev/null +++ b/TwidereSDK/Sources/TwidereUI/UIViewRepresentable/PrototypeStatusViewRepresentable.swift @@ -0,0 +1,103 @@ +// +// PrototypeStatusViewRepresentable.swift +// +// +// Created by MainasuK on 2022-7-25. +// + +import SwiftUI +import TwitterMeta +import TwidereCore + +//public struct PrototypeStatusViewRepresentable: UIViewRepresentable { +// +// private let now = Date() +// +// let style: Style +// let configurationContext: StatusView.ConfigurationContext +// +// @Binding var height: CGFloat +// +// public init( +// style: Style, +// configurationContext: StatusView.ConfigurationContext, +// height: Binding +// ) { +// self.style = style +// self.configurationContext = configurationContext +// self._height = height +// } +// +// public func makeUIView(context: Context) -> PrototypeStatusView { +// let view = PrototypeStatusView() +// switch style { +// case .timeline: +// view.statusView.setup(style: .inline) +// view.statusView.toolbar.setup(style: .inline) +// case .thread: +// view.statusView.setup(style: .plain) +// view.statusView.toolbar.setup(style: .plain) +// } +// view.delegate = context.coordinator +// +// view.translatesAutoresizingMaskIntoConstraints = false +// view.setContentCompressionResistancePriority(.required, for: .vertical) +// +// view.statusView.prepareForReuse() +// view.statusView.viewModel.timestamp = now +// view.statusView.viewModel.dateTimeProvider = configurationContext.dateTimeProvider +// +// return view +// } +// +// public func updateUIView(_ view: PrototypeStatusView, context: Context) { +// let statusView = view.statusView +// statusView.viewModel.authorAvatarImage = Asset.Scene.Preference.twidereAvatar.image +// statusView.viewModel.authorName = PlaintextMetaContent(string: "Twidere") +// statusView.viewModel.authorUsername = "TwidereProject" +// +// let content = TwitterContent(content: L10n.Scene.Settings.Display.Preview.thankForUsingTwidereX) +// statusView.viewModel.content = TwitterMetaContent.convert( +// content: content, +// urlMaximumLength: 16, +// twitterTextProvider: configurationContext.twitterTextProvider +// ) +// +// view.setNeedsLayout() +// view.layoutIfNeeded() +// } +// +// public func makeCoordinator() -> Coordinator { +// Coordinator(self) +// } +// +//} +// +//extension PrototypeStatusViewRepresentable { +// +// public class Coordinator: PrototypeStatusViewDelegate { +// +// let representable: PrototypeStatusViewRepresentable +// +// init(_ representable: PrototypeStatusViewRepresentable) { +// self.representable = representable +// } +// +// public func layoutDidUpdate(_ view: PrototypeStatusView) { +// DispatchQueue.main.async { +// self.representable.height = view.statusView.frame.height +// } +// } +// +// } +// +//} +// +//extension PrototypeStatusViewRepresentable { +// +// public enum Style: Hashable, CaseIterable { +// case timeline +// case thread +// } +// +//} diff --git a/TwidereSDK/Sources/TwidereUI/UIViewRepresentable/ReplyStatusViewRepresentable.swift b/TwidereSDK/Sources/TwidereUI/UIViewRepresentable/ReplyStatusViewRepresentable.swift index a3a13c82..9acc3985 100644 --- a/TwidereSDK/Sources/TwidereUI/UIViewRepresentable/ReplyStatusViewRepresentable.swift +++ b/TwidereSDK/Sources/TwidereUI/UIViewRepresentable/ReplyStatusViewRepresentable.swift @@ -9,31 +9,31 @@ import UIKit import SwiftUI import TwidereCore -public struct ReplyStatusViewRepresentable: UIViewRepresentable { - - let statusObject: StatusObject - let configurationContext: StatusView.ConfigurationContext - let width: CGFloat - - public func makeUIView(context: Context) -> ReplyStatusView { - let view = ReplyStatusView() - // using view `intrinsicContentSize` for layout - view.translatesAutoresizingMaskIntoConstraints = false - return view - } - - public func updateUIView(_ view: ReplyStatusView, context: Context) { - if width != .zero { - view.statusView.frame.size.width = width - view.widthLayoutConstraint.constant = width - view.widthLayoutConstraint.isActive = true - } - - view.statusView.prepareForReuse() - view.statusView.configure( - statusObject: statusObject, - configurationContext: configurationContext - ) - } - -} +//public struct ReplyStatusViewRepresentable: UIViewRepresentable { +// +// let statusObject: StatusObject +// let configurationContext: StatusView.ConfigurationContext +// let width: CGFloat +// +// public func makeUIView(context: Context) -> ReplyStatusView { +// let view = ReplyStatusView() +// // using view `intrinsicContentSize` for layout +// view.translatesAutoresizingMaskIntoConstraints = false +// return view +// } +// +// public func updateUIView(_ view: ReplyStatusView, context: Context) { +// if width != .zero { +// view.statusView.frame.size.width = width +// view.widthLayoutConstraint.constant = width +// view.widthLayoutConstraint.isActive = true +// } +// +// view.statusView.prepareForReuse() +// view.statusView.configure( +// statusObject: statusObject, +// configurationContext: configurationContext +// ) +// } +// +//} diff --git a/TwidereSDK/Sources/TwidereUI/UIViewRepresentable/TextViewRepresentable.swift b/TwidereSDK/Sources/TwidereUI/UIViewRepresentable/TextViewRepresentable.swift new file mode 100644 index 00000000..3c066b79 --- /dev/null +++ b/TwidereSDK/Sources/TwidereUI/UIViewRepresentable/TextViewRepresentable.swift @@ -0,0 +1,246 @@ +// +// TextViewRepresentable.swift +// +// +// Created by MainasuK on 2023/2/3. +// + +import os.log +import UIKit +import SwiftUI +import TwidereCore +import MetaTextKit +import MetaTextArea + +public struct TextViewRepresentable: UIViewRepresentable { + // let logger = Logger(subsystem: "TextViewRepresentable", category: "View") + let logger = Logger(.disabled) + + // input + let metaContent: MetaContent + let textStyle: TextStyle + let width: CGFloat + let isSelectable: Bool + let handler: (Meta?) -> Void + + // output + let attributedString: NSAttributedString + + public init( + metaContent: MetaContent, + textStyle: TextStyle, + width: CGFloat, + isSelectable: Bool, + handler: @escaping (Meta?) -> Void + ) { + self.metaContent = metaContent + self.textStyle = textStyle + self.width = width + self.isSelectable = isSelectable + self.handler = handler + self.attributedString = { + let attributedString = NSMutableAttributedString(string: metaContent.string) + let textAttributes: [NSAttributedString.Key: Any] = [ + .font: textStyle.font, + .foregroundColor: textStyle.textColor, + ] + let linkAttributes: [NSAttributedString.Key: Any] = [ + .font: textStyle.font, + .foregroundColor: UIColor.tintColor, + ] + let paragraphStyle: NSMutableParagraphStyle = { + let style = NSMutableParagraphStyle() + style.lineSpacing = 3 + style.paragraphSpacing = 8 + return style + }() + + MetaText.setAttributes( + for: attributedString, + textAttributes: textAttributes, + linkAttributes: linkAttributes, + paragraphStyle: paragraphStyle, + content: metaContent + ) + + return attributedString + }() + } + + public func makeUIView(context: Context) -> UITextView { + let textView: WrappedTextView = { + let textView = WrappedTextView() + textView.backgroundColor = .clear + textView.isScrollEnabled = false + textView.isEditable = false + textView.isSelectable = false + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0 + textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + textView.setContentHuggingPriority(.defaultHigh, for: .vertical) + return textView + }() + textView.isSelectable = isSelectable + textView.delegate = context.coordinator + textView.textViewDelegate = context.coordinator + textView.frame.size.width = width + textView.textStorage.setAttributedString(attributedString) + textView.invalidateIntrinsicContentSize() + textView.setNeedsLayout() + textView.layoutIfNeeded() + return textView + } + + public func updateUIView(_ view: UITextView, context: Context) { + let textView = view + + var needsLayout = false + + if textView.frame.size.width != width { + textView.frame.size.width = width + needsLayout = true + } + if textView.attributedText.string != attributedString.string { + textView.textStorage.setAttributedString(attributedString) + needsLayout = true + + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): update textView \(view.hashValue): \(metaContent.string)") + } else { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): reuse textView content") + } + + if needsLayout { + textView.invalidateIntrinsicContentSize() + textView.setNeedsLayout() + textView.layoutIfNeeded() + } + } + + public func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + public class Coordinator: NSObject { + let logger = Logger(subsystem: "TextViewRepresentable", category: "Coordinator") + + let view: TextViewRepresentable + + init(_ view: TextViewRepresentable) { + self.view = view + super.init() + } + } +} + +// MARK: - UITextViewDelegate +extension TextViewRepresentable.Coordinator: UITextViewDelegate { + public func textView(_ textView: UITextView, shouldInteractWith textAttachment: NSTextAttachment, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + return false + } + + public func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + return false + } +} + +// MARK: - WrappedTextViewDelegate +extension TextViewRepresentable.Coordinator: WrappedTextViewDelegate { + public func wrappedTextView(_ wrappedTextView: WrappedTextView, didSelectMeta meta: Meta?) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): meta: \(meta.debugDescription)") + view.handler(meta) + } +} + +public protocol WrappedTextViewDelegate: AnyObject { + func wrappedTextView(_ wrappedTextView: WrappedTextView, didSelectMeta meta: Meta?) +} + +public class WrappedTextView: UITextView { + + let logger = Logger(subsystem: "WrappedTextView", category: "View") + + let tapGestureRecognizer = UITapGestureRecognizer() + + private var lastWidth: CGFloat = 0 + + public weak var textViewDelegate: WrappedTextViewDelegate? + + public override init(frame: CGRect, textContainer: NSTextContainer?) { + super.init(frame: frame, textContainer: textContainer) + // end init + + addGestureRecognizer(tapGestureRecognizer) + tapGestureRecognizer.addTarget(self, action: #selector(WrappedTextView.tapGestureRecognizerHandler(_:))) + tapGestureRecognizer.delaysTouchesBegan = false + } + + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func layoutSubviews() { + super.layoutSubviews() + + if bounds.width != lastWidth { + lastWidth = bounds.width + invalidateIntrinsicContentSize() + } + } + + public override var intrinsicContentSize: CGSize { + let size = sizeThatFits(CGSize( + width: lastWidth, + height: UIView.layoutFittingExpandedSize.height + )) + return CGSize( + width: lastWidth, + height: size.height.rounded(.up) + ) + } + +} + +extension WrappedTextView { + @objc private func tapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + switch sender.state { + case .ended: + let point = sender.location(in: self) + let meta = meta(at: point) + textViewDelegate?.wrappedTextView(self, didSelectMeta: meta) + default: + break + } + } +} + +extension WrappedTextView { + + public override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + return true + } + + func meta(at point: CGPoint) -> Meta? { + guard let fragment = textLayoutManager?.textLayoutFragment(for: point) else { return nil } + + let pointInFragmentFrame = CGPoint( + x: point.x - fragment.layoutFragmentFrame.origin.x, + y: point.y - fragment.layoutFragmentFrame.origin.y + ) + let lines = fragment.textLineFragments + guard let lineIndex = lines.firstIndex(where: { $0.typographicBounds.contains(pointInFragmentFrame) }) else { return nil } + guard lineIndex < lines.count else { return nil } + let line = lines[lineIndex] + + let characterIndex = line.characterIndex(for: point) + guard characterIndex >= 0, characterIndex < line.attributedString.length else { return nil } + + guard let meta = line.attributedString.attribute(.meta, at: characterIndex, effectiveRange: nil) as? Meta else { + return nil + } + return meta + } + +} diff --git a/TwidereSDK/Sources/TwidereUI/UIViewRepresentable/TouchBlockingViewRepresentable.swift b/TwidereSDK/Sources/TwidereUI/UIViewRepresentable/TouchBlockingViewRepresentable.swift new file mode 100644 index 00000000..a7086686 --- /dev/null +++ b/TwidereSDK/Sources/TwidereUI/UIViewRepresentable/TouchBlockingViewRepresentable.swift @@ -0,0 +1,21 @@ +// +// TouchBlockingViewRepresentable.swift +// +// +// Created by MainasuK on 2023/3/22. +// + +import SwiftUI + +public struct TouchBlockingViewRepresentable: UIViewRepresentable { + + public func makeUIView(context: Context) -> TouchBlockingView { + let view = TouchBlockingView() + return view + } + + public func updateUIView(_ view: TouchBlockingView, context: Context) { + // do nothing + } + +} diff --git a/TwidereSDK/Sources/TwidereUI/UIViewRepresentable/WrapperViewRepresentable.swift b/TwidereSDK/Sources/TwidereUI/UIViewRepresentable/WrapperViewRepresentable.swift new file mode 100644 index 00000000..4f52d319 --- /dev/null +++ b/TwidereSDK/Sources/TwidereUI/UIViewRepresentable/WrapperViewRepresentable.swift @@ -0,0 +1,24 @@ +// +// WrapperViewRepresentable.swift +// +// +// Created by MainasuK on 2023/3/17. +// + +import UIKit +import SwiftUI +import TwidereCore + +public struct WrapperViewRepresentable: UIViewRepresentable { + + public let view: UIView + + public func makeUIView(context: Context) -> UIView { + return view + } + + public func updateUIView(_ view: UIView, context: Context) { + // do nothing + } + +} diff --git a/TwidereSDK/Sources/TwidereUI/Utility/PreferenceKeys.swift b/TwidereSDK/Sources/TwidereUI/Utility/PreferenceKeys.swift new file mode 100644 index 00000000..6b5ae8ab --- /dev/null +++ b/TwidereSDK/Sources/TwidereUI/Utility/PreferenceKeys.swift @@ -0,0 +1,23 @@ +// +// PreferenceKeys.swift +// +// +// Created by MainasuK on 2023/2/9. +// + +import UIKit +import SwiftUI + +struct ViewHeightKey: PreferenceKey { + static var defaultValue: CGFloat { 0 } + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = value + nextValue() + } +} + +struct ViewFrameKey: PreferenceKey { + static var defaultValue: CGRect { .zero } + static func reduce(value: inout CGRect, nextValue: () -> CGRect) { + value = nextValue() + } +} diff --git a/TwidereSDK/Sources/TwidereUI/Utility/SizeDimensionPreferenceKey.swift b/TwidereSDK/Sources/TwidereUI/Utility/SizeDimensionPreferenceKey.swift new file mode 100644 index 00000000..29aa39a8 --- /dev/null +++ b/TwidereSDK/Sources/TwidereUI/Utility/SizeDimensionPreferenceKey.swift @@ -0,0 +1,19 @@ +// +// SizeDimensionPreferenceKey.swift +// +// +// Created by MainasuK on 2023/4/10. +// + +import SwiftUI + +public struct SizeDimensionPreferenceKey: PreferenceKey { + public static let defaultValue: CGFloat = 0 + + public static func reduce( + value: inout CGFloat, + nextValue: () -> CGFloat + ) { + value = max(value, nextValue()) + } +} diff --git a/TwidereSDK/Sources/TwidereUI/Utility/ViewLayoutFrame.swift b/TwidereSDK/Sources/TwidereUI/Utility/ViewLayoutFrame.swift new file mode 100644 index 00000000..db92e782 --- /dev/null +++ b/TwidereSDK/Sources/TwidereUI/Utility/ViewLayoutFrame.swift @@ -0,0 +1,55 @@ +// +// ViewLayoutFrame.swift +// +// +// Created by MainasuK on 2023/2/3. +// + +import os.log +import UIKit +import CoreGraphics + +public struct ViewLayoutFrame { + let logger = Logger(subsystem: "ViewLayoutFrame", category: "ViewLayoutFrame") + + public var layoutFrame: CGRect + public var safeAreaLayoutFrame: CGRect + public var readableContentLayoutFrame: CGRect + + public init( + layoutFrame: CGRect = .zero, + safeAreaLayoutFrame: CGRect = .zero, + readableContentLayoutFrame: CGRect = .zero + ) { + self.layoutFrame = layoutFrame + self.safeAreaLayoutFrame = safeAreaLayoutFrame + self.readableContentLayoutFrame = readableContentLayoutFrame + } +} + +extension ViewLayoutFrame { + public mutating func update(view: UIView) { + guard view.window != nil else { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): layoutFrame update for a view without attached window. Skip this invalid update") + return + } + + let layoutFrame = view.frame + if self.layoutFrame != layoutFrame { + self.layoutFrame = layoutFrame + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): layoutFrame: \(layoutFrame.debugDescription)") + } + + let safeAreaLayoutFrame = view.safeAreaLayoutGuide.layoutFrame + if self.safeAreaLayoutFrame != safeAreaLayoutFrame { + self.safeAreaLayoutFrame = safeAreaLayoutFrame + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): safeAreaLayoutFrame: \(safeAreaLayoutFrame.debugDescription)") + } + + let readableContentLayoutFrame = view.readableContentGuide.layoutFrame + if self.readableContentLayoutFrame != readableContentLayoutFrame { + self.readableContentLayoutFrame = readableContentLayoutFrame + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): readableContentLayoutFrame: \(readableContentLayoutFrame.debugDescription)") + } + } +} diff --git a/TwidereSDK/Sources/TwidereUI/Vender/TapCountRecognizerModifier.swift b/TwidereSDK/Sources/TwidereUI/Vender/TapCountRecognizerModifier.swift new file mode 100644 index 00000000..2fddfa44 --- /dev/null +++ b/TwidereSDK/Sources/TwidereUI/Vender/TapCountRecognizerModifier.swift @@ -0,0 +1,111 @@ +// +// TapCountRecognizerModifier.swift +// +// +// Created by MainasuK on 2022-7-28. +// + +import UIKit +import SwiftUI + +// ref: +// https://stackoverflow.com/questions/65062833/handling-both-double-and-triple-gesture-recognisers-in-swiftui/66979241#66979241 + +public struct TapCountRecognizerModifier: ViewModifier { + + let tapSensitivity: Int + let singleTapAction: (() -> Void)? + let doubleTapAction: (() -> Void)? + let tripleTapAction: (() -> Void)? + + + public init(tapSensitivity: Int = 250, singleTapAction: (() -> Void)? = nil, doubleTapAction: (() -> Void)? = nil, tripleTapAction: (() -> Void)? = nil) { + + self.tapSensitivity = ((tapSensitivity >= 0) ? tapSensitivity : 250) + self.singleTapAction = singleTapAction + self.doubleTapAction = doubleTapAction + self.tripleTapAction = tripleTapAction + + } + + @State private var tapCount: Int = Int() + @State private var currentDispatchTimeID: DispatchTime = DispatchTime.now() + + public func body(content: Content) -> some View { + + return content + .gesture(fundamentalGesture) + + } + + var fundamentalGesture: some Gesture { + + DragGesture(minimumDistance: 0.0, coordinateSpace: .local) + .onEnded() { _ in tapCount += 1; tapAnalyzerFunction() } + + } + + + + func tapAnalyzerFunction() { + + currentDispatchTimeID = dispatchTimeIdGenerator(deadline: tapSensitivity) + + if tapCount == 1 { + + let singleTapGestureDispatchTimeID: DispatchTime = currentDispatchTimeID + + DispatchQueue.main.asyncAfter(deadline: singleTapGestureDispatchTimeID) { + + if (singleTapGestureDispatchTimeID == currentDispatchTimeID) { + + if let unwrappedSingleTapAction: () -> Void = singleTapAction { unwrappedSingleTapAction() } + + tapCount = 0 + + } + + } + + } + else if tapCount == 2 { + + let doubleTapGestureDispatchTimeID: DispatchTime = currentDispatchTimeID + + DispatchQueue.main.asyncAfter(deadline: doubleTapGestureDispatchTimeID) { + + if (doubleTapGestureDispatchTimeID == currentDispatchTimeID) { + + if let unwrappedDoubleTapAction: () -> Void = doubleTapAction { unwrappedDoubleTapAction() } + + tapCount = 0 + + } + + } + + } + else { + + + if let unwrappedTripleTapAction: () -> Void = tripleTapAction { unwrappedTripleTapAction() } + + tapCount = 0 + + } + + } + + func dispatchTimeIdGenerator(deadline: Int) -> DispatchTime { return DispatchTime.now() + DispatchTimeInterval.milliseconds(deadline) } + +} + +extension View { + + public func tapCountRecognizer(tapSensitivity: Int = 250, singleTapAction: (() -> Void)? = nil, doubleTapAction: (() -> Void)? = nil, tripleTapAction: (() -> Void)? = nil) -> some View { + + return self.modifier(TapCountRecognizerModifier(tapSensitivity: tapSensitivity, singleTapAction: singleTapAction, doubleTapAction: doubleTapAction, tripleTapAction: tripleTapAction)) + + } + +} diff --git a/TwidereSDK/Sources/TwidereUI/Vender/VectorImageView.swift b/TwidereSDK/Sources/TwidereUI/Vender/VectorImageView.swift index 901e6dcd..0b0f9041 100644 --- a/TwidereSDK/Sources/TwidereUI/Vender/VectorImageView.swift +++ b/TwidereSDK/Sources/TwidereUI/Vender/VectorImageView.swift @@ -19,7 +19,7 @@ public struct VectorImageView: UIViewRepresentable { public init( image: UIImage, contentMode: UIView.ContentMode = .scaleAspectFit, - tintColor: UIColor = .black + tintColor: UIColor = UIColor.tintColor ) { self.image = image self.contentMode = contentMode @@ -28,10 +28,7 @@ public struct VectorImageView: UIViewRepresentable { public func makeUIView(context: Context) -> UIImageView { let imageView = UIImageView() - imageView.setContentCompressionResistancePriority( - .fittingSizeLevel, - for: .vertical - ) + imageView.setContentCompressionResistancePriority(.fittingSizeLevel,for: .vertical) return imageView } diff --git a/TwidereSDK/Sources/TwitterSDK/API/Error/Twitter+API+Error+InternalError.swift b/TwidereSDK/Sources/TwitterSDK/API/Error/Twitter+API+Error+InternalError.swift deleted file mode 100644 index c1d0fa2e..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/Error/Twitter+API+Error+InternalError.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// Twitter+API+Error+InternalError.swift -// -// -// Created by Cirno MainasuK on 2020-12-25. -// - -import Foundation - -extension Twitter.API.Error { - public struct InternalError: Error, LocalizedError { - let message: String - - public init(message: String) { - self.message = message - } - - public var errorDescription: String? { - return "Internal Error" - } - - public var failureReason: String? { - return message - } - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/Error/Twitter+API+Error+ResponseError.swift b/TwidereSDK/Sources/TwitterSDK/API/Error/Twitter+API+Error+ResponseError.swift deleted file mode 100644 index e9815b16..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/Error/Twitter+API+Error+ResponseError.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// Twitter+API+ResponseError.swift -// -// -// Created by Cirno MainasuK on 2020-12-25. -// - -import Foundation -import NIOHTTP1 - -extension Twitter.API.Error { - public struct ResponseError: Error { - public var httpResponseStatus: HTTPResponseStatus - public var twitterAPIError: TwitterAPIError? - - public init(httpResponseStatus: HTTPResponseStatus, twitterAPIError: Twitter.API.Error.TwitterAPIError?) { - self.httpResponseStatus = httpResponseStatus - self.twitterAPIError = twitterAPIError - } - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/Error/Twitter+API+Error+TwitterAPIError.swift b/TwidereSDK/Sources/TwitterSDK/API/Error/Twitter+API+Error+TwitterAPIError.swift deleted file mode 100644 index d8ac4ec1..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/Error/Twitter+API+Error+TwitterAPIError.swift +++ /dev/null @@ -1,86 +0,0 @@ -// -// File.swift -// -// -// Created by Cirno MainasuK on 2020-12-25. -// - -import Foundation - -// Ref: https://developer.twitter.com/en/support/twitter-api/error-troubleshooting -// Ref: https://developer.twitter.com/ja/docs/basics/response-codes (prefer) -extension Twitter.API.Error { - public enum TwitterAPIError: Error, Hashable { - - case custom(code: Int, message: String) - - // 63 - User has been suspended. Corresponds with HTTP 403 The user account has been suspended and information cannot be retrieved. - case userHasBeenSuspended - - // 88 - Corresponds with HTTP 429. The request limit for this resource has been reached for the current rate limit window. - case rateLimitExceeded - - // 136 - - case blockedFromViewingThisUserProfile - - // 162 - - case blockedFromRequestFollowingThisUser - - // 179 - Sorry, you are not authorized to see this status - case notAuthorizedToSeeThisStatus - - // 326 - Corresponds with HTTP 403. The user should log in to https://twitter.com to unlock their account before the user token can be used. - case accountIsTemporarilyLocked(message: String) - - init(code: Int, message: String = "") { - switch code { - case 63: self = .userHasBeenSuspended - case 88: self = .rateLimitExceeded - case 136: self = .blockedFromViewingThisUserProfile - case 162: self = .blockedFromRequestFollowingThisUser - case 179: self = .notAuthorizedToSeeThisStatus - case 326: self = .accountIsTemporarilyLocked(message: message) - default: self = .custom(code: code, message: message) - } - } - - init?(errorResponse: Twitter.API.ErrorResponse) { - guard let error = errorResponse.errors.first else { - return nil - } - - self.init(code: error.code, message: error.message) - } - - init?(errorResponseV2: Twitter.API.ErrorResponseV2) { - guard let error = errorResponseV2.errors.first else { - return nil - } - - if let title = error.title, title == "Authorization Error" { - self = .notAuthorizedToSeeThisStatus - return - } - - return nil - } - - init?(errorRequestResponse: Twitter.API.ErrorRequestResponse) { - switch (errorRequestResponse.request, errorRequestResponse.error) { - case (_, "Not authorized."): - self = .notAuthorizedToSeeThisStatus - default: - return nil - } - } - - public init?(responseContentError error: Twitter.Response.V2.ContentError) { - switch (error.title, error.detail) { - case ("Forbidden", let detail) where detail.hasPrefix("User has been suspended"): - self = .userHasBeenSuspended - default: - return nil - } - } - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Account.swift b/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Account.swift deleted file mode 100644 index d0f9bc16..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Account.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// Twitter+API+Account.swift -// -// -// Created by Cirno MainasuK on 2020-9-28. -// - -import Foundation -import Combine - -extension Twitter.API.Account { - - static let verifyCredentialsEndpointURL = Twitter.API.endpointURL.appendingPathComponent("account/verify_credentials.json") - - public static func verifyCredentials( - session: URLSession, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: verifyCredentialsEndpointURL, - httpMethod: "GET", - authorization: authorization, - queryItems: nil - ) - - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: Twitter.Entity.User.self, from: data, response: response) - - return Twitter.Response.Content(value: value, response: response) - } - -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Application.swift b/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Application.swift deleted file mode 100644 index dae290ae..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Application.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// Twitter+API+Application.swift -// -// -// Created by Cirno MainasuK on 2020-12-7. -// - -import Foundation -import Combine - -extension Twitter.API.Application { - - static let rateLimitStatusEndpointURL = Twitter.API.endpointURL.appendingPathComponent("application/rate_limit_status.json") - - public static func rateLimitStatus( - session: URLSession, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: rateLimitStatusEndpointURL, - method: .GET, - query: nil, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: Twitter.Entity.RateLimitStatus.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Block.swift b/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Block.swift deleted file mode 100644 index 2c5dd26b..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Block.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// Twitter+API+Block.swift -// -// -// Created by Cirno MainasuK on 2021-1-13. -// - -import Foundation -import Combine - -extension Twitter.API.Block { - - static let createEndpointURL = Twitter.API.endpointURL.appendingPathComponent("blocks/create.json") - static let destroyEndpointURL = Twitter.API.endpointURL.appendingPathComponent("blocks/destroy.json") - - public static func block(session: URLSession, authorization: Twitter.API.OAuth.Authorization, query: BlockUpdateQuery) -> AnyPublisher, Error> { - let url: URL = { - switch query.queryKind { - case .create: return createEndpointURL - case .destroy: return destroyEndpointURL - } - }() - var request = Twitter.API.request(url: url, httpMethod: "POST", authorization: authorization, queryItems: query.queryItems) - request.httpMethod = "POST" - return session.dataTaskPublisher(for: request) - .tryMap { data, response in - let value = try Twitter.API.decode(type: Twitter.Entity.User.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - .eraseToAnyPublisher() - } - -} - -extension Twitter.API.Block { - - public struct BlockUpdateQuery { - public let userID: Twitter.Entity.User.ID - public let queryKind: QueryKind - - public enum QueryKind { - case create - case destroy - } - - public init(userID: Twitter.Entity.User.ID, queryKind: Twitter.API.Block.BlockUpdateQuery.QueryKind) { - self.userID = userID - self.queryKind = queryKind - } - - var queryItems: [URLQueryItem]? { - var items: [URLQueryItem] = [] - items.append(URLQueryItem(name: "user_id", value: userID)) - guard !items.isEmpty else { return nil } - return items - } - } - -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Favorites.swift b/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Favorites.swift deleted file mode 100644 index 476e1937..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Favorites.swift +++ /dev/null @@ -1,109 +0,0 @@ -// -// Twitter+API+Favorites.swift -// -// -// Created by Cirno MainasuK on 2020-10-13. -// - -import Foundation -import Combine - -// TODO: V2 - -extension Twitter.API.Favorites { - - static let favoritesCreateEndpointURL = Twitter.API.endpointURL.appendingPathComponent("favorites/create.json") - static let favoritesDestroyEndpointURL = Twitter.API.endpointURL.appendingPathComponent("favorites/destroy.json") - - public static func favorites(session: URLSession, authorization: Twitter.API.OAuth.Authorization, favoriteKind: FavoriteKind, query: FavoriteQuery) -> AnyPublisher, Error> { - let url: URL = { - switch favoriteKind { - case .create: return favoritesCreateEndpointURL - case .destroy: return favoritesDestroyEndpointURL - } - }() - var request = Twitter.API.request(url: url, httpMethod: "POST", authorization: authorization, queryItems: query.queryItems) - request.httpMethod = "POST" - return session.dataTaskPublisher(for: request) - .tryMap { data, response in - let value = try Twitter.API.decode(type: Twitter.Entity.Tweet.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - .eraseToAnyPublisher() - } -} - -extension Twitter.API.Favorites { - - public enum FavoriteKind { - case create - case destroy - } - - public struct FavoriteQuery { - public let id: Twitter.Entity.Tweet.ID - - public init(id: Twitter.Entity.Tweet.ID) { - self.id = id - } - - var queryItems: [URLQueryItem]? { - var items: [URLQueryItem] = [] - items.append(URLQueryItem(name: "id", value: id)) - guard !items.isEmpty else { return nil } - return items - } - } - - public struct ListQuery { - public let count: Int? - public let userID: String? - public let maxID: String? - - public init(count: Int? = nil, userID: Twitter.Entity.User.ID? = nil, maxID: String? = nil) { - self.count = count - self.userID = userID - self.maxID = maxID - } - - var queryItems: [URLQueryItem]? { - var items: [URLQueryItem] = [] - if let count = self.count { - items.append(URLQueryItem(name: "count", value: String(count))) - } - if let userID = self.userID { - items.append(URLQueryItem(name: "user_id", value: userID)) - } - if let maxID = self.maxID { - items.append(URLQueryItem(name: "max_id", value: maxID)) - } - guard !items.isEmpty else { return nil } - return items - } - } -} - -extension Twitter.API.Favorites { - - static let listEndpointURL = Twitter.API.endpointURL.appendingPathComponent("favorites/list.json") - - // V1 - public static func list( - session: URLSession, - query: Twitter.API.Statuses.Timeline.TimelineQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content<[Twitter.Entity.Tweet]> { - assert(query.userID != nil && query.userID != "") - let request = Twitter.API.request( - url: listEndpointURL, - method: .GET, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: [Twitter.Entity.Tweet].self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Friendships.swift b/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Friendships.swift deleted file mode 100644 index 1c08ee8a..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Friendships.swift +++ /dev/null @@ -1,106 +0,0 @@ -// -// File.swift -// -// -// Created by Cirno MainasuK on 2020-11-2. -// - -import Foundation -import Combine - -extension Twitter.API.Friendships { - - static let showEndpointURL = Twitter.API.endpointURL.appendingPathComponent("friendships/show.json") // 180 in 15m - - public static func friendship( - session: URLSession, - query: FriendshipQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: showEndpointURL, - method: .GET, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: Twitter.Entity.Relationship.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - public struct FriendshipQuery: Query { - - public let sourceID: Twitter.Entity.User.ID - public let targetID: Twitter.Entity.User.ID - - public init(sourceID: Twitter.Entity.User.ID, targetID: Twitter.Entity.User.ID) { - self.sourceID = sourceID - self.targetID = targetID - } - - var queryItems: [URLQueryItem]? { - var items: [URLQueryItem] = [] - items.append(URLQueryItem(name: "source_id", value: sourceID)) - items.append(URLQueryItem(name: "target_id", value: targetID)) - guard !items.isEmpty else { return nil } - return items - } - var encodedQueryItems: [URLQueryItem]? { nil } - var formQueryItems: [URLQueryItem]? { nil } - var contentType: String? { nil } - var body: Data? { nil } - } - -} - -extension Twitter.API.Friendships { - - static let createEndpointURL = Twitter.API.endpointURL.appendingPathComponent("friendships/create.json") // 400 in 1 day - static let destroyEndpointURL = Twitter.API.endpointURL.appendingPathComponent("friendships/destroy.json") - static let updateEndpointURL = Twitter.API.endpointURL.appendingPathComponent("friendships/update.json") - - @available(*, deprecated, message: "use V2") - public static func friendships(session: URLSession, authorization: Twitter.API.OAuth.Authorization, queryKind: UpdateQueryType, query: FriendshipUpdateQuery) -> AnyPublisher, Error> { - let url: URL = { - switch queryKind { - case .create: return createEndpointURL - case .destroy: return destroyEndpointURL - case .update: return updateEndpointURL - } - }() - var request = Twitter.API.request(url: url, httpMethod: "POST", authorization: authorization, queryItems: query.queryItems) - request.httpMethod = "POST" - return session.dataTaskPublisher(for: request) - .tryMap { data, response in - let value = try Twitter.API.decode(type: Twitter.Entity.User.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - .eraseToAnyPublisher() - } - -} - -extension Twitter.API.Friendships { - - public enum UpdateQueryType { - case create - case destroy - case update - } - - public struct FriendshipUpdateQuery { - public let userID: Twitter.Entity.User.ID - - public init(userID: Twitter.Entity.User.ID) { - self.userID = userID - } - - var queryItems: [URLQueryItem]? { - var items: [URLQueryItem] = [] - items.append(URLQueryItem(name: "user_id", value: userID)) - guard !items.isEmpty else { return nil } - return items - } - } - -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Geo.swift b/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Geo.swift deleted file mode 100644 index cf1b7f81..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Geo.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// Twitter+API+Geo.swift -// -// -// Created by Cirno MainasuK on 2020-10-27. -// - -import Foundation -import Combine - -extension Twitter.API.Geo { - - static let searchEndpointURL = Twitter.API.endpointURL.appendingPathComponent("geo/search.json") - - public static func search( - session: URLSession, - query: SearchQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: searchEndpointURL, - method: .GET, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: SearchResponse.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - public struct SearchQuery: Query { - public let latitude: Double - public let longitude: Double - public let granularity: String - - public init( - latitude: Double, - longitude: Double, - granularity: String - ) { - self.latitude = latitude - self.longitude = longitude - self.granularity = granularity - } - - var queryItems: [URLQueryItem]? { - var items: [URLQueryItem] = [] - items.append(URLQueryItem(name: "lat", value: "\(latitude)")) - items.append(URLQueryItem(name: "long", value: "\(longitude)")) - items.append(URLQueryItem(name: "granularity", value: granularity)) - guard !items.isEmpty else { return nil } - return items - } - var encodedQueryItems: [URLQueryItem]? { nil } - var formQueryItems: [URLQueryItem]? { nil } - var contentType: String? { nil } - var body: Data? { nil } - } - - public struct SearchResponse: Codable { - public let result: Result - - public struct Result: Codable { - public let places: [Twitter.Entity.Place] - } - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+List.swift b/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+List.swift deleted file mode 100644 index 299bf6f7..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+List.swift +++ /dev/null @@ -1,107 +0,0 @@ -// -// Twitter+API+List.swift -// -// -// Created by MainasuK on 2022-3-10. -// - -import Foundation -import Combine - -// https://developer.twitter.com/en/docs/twitter-api/v1/accounts-and-users/create-manage-lists/api-reference/get-lists-show -extension Twitter.API.List { - - static let showEndpointURL = Twitter.API.endpointURL - .appendingPathComponent("lists") - .appendingPathComponent("show.json") - - public static func show( - session: URLSession, - query: ShowQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: showEndpointURL, - method: .GET, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: Twitter.Entity.List.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - public struct ShowQuery: Query { - public let id: Twitter.Entity.List.ID - - public init( - id: Twitter.Entity.List.ID - ) { - self.id = id - } - - var queryItems: [URLQueryItem]? { - var items: [URLQueryItem] = [] - items.append(URLQueryItem(name: "list_id", value: id)) - guard !items.isEmpty else { return nil } - return items - } - var encodedQueryItems: [URLQueryItem]? { nil } - var formQueryItems: [URLQueryItem]? { nil } - var contentType: String? { nil } - var body: Data? { nil } - } - -} - -// https://developer.twitter.com/en/docs/twitter-api/v1/accounts-and-users/create-manage-lists/api-reference/get-lists-statuses -extension Twitter.API.List { - - static let statusesEndpointURL = Twitter.API.endpointURL - .appendingPathComponent("lists") - .appendingPathComponent("statuses.json") - - public static func statuses( - session: URLSession, - query: StatusesQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content<[Twitter.Entity.Tweet]> { - let request = Twitter.API.request( - url: statusesEndpointURL, - method: .GET, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: [Twitter.Entity.Tweet].self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - public struct StatusesQuery: Query { - public let id: Twitter.Entity.List.ID - public let maxID: Twitter.Entity.Tweet.ID? - - public init( - id: Twitter.Entity.List.ID, - maxID: Twitter.Entity.Tweet.ID? - ) { - self.id = id - self.maxID = maxID - } - - var queryItems: [URLQueryItem]? { - var items: [URLQueryItem] = [] - items.append(URLQueryItem(name: "list_id", value: id)) - maxID.flatMap { - items.append(URLQueryItem(name: "max_id", value: $0)) - } - guard !items.isEmpty else { return nil } - return items - } - var encodedQueryItems: [URLQueryItem]? { nil } - var formQueryItems: [URLQueryItem]? { nil } - var contentType: String? { nil } - var body: Data? { nil } - } - -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Lookup.swift b/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Lookup.swift deleted file mode 100644 index 3af59961..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Lookup.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// Twitter+API+Lookup.swift -// -// -// Created by Cirno MainasuK on 2020-12-15. -// - -import Foundation -import Combine - -extension Twitter.API.Lookup { - - static let statusesLookupEndpointURL = Twitter.API.endpointURL.appendingPathComponent("statuses/lookup.json") - - public static func tweets( - session: URLSession, - query: LookupQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content<[Twitter.Entity.Tweet]> { - let request = Twitter.API.request( - url: statusesLookupEndpointURL, - method: .GET, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: [Twitter.Entity.Tweet].self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - public struct LookupQuery: Query { - public let ids: [String] - - public init(ids: [String]) { - self.ids = ids - } - - var queryItems: [URLQueryItem]? { - var items: [URLQueryItem] = [] - - let ids = self.ids.joined(separator: ",") - // "id" not typo - items.append(URLQueryItem(name: "id", value: ids)) - items.append(URLQueryItem(name: "include_entities", value: "true")) - items.append(URLQueryItem(name: "include_ext_alt_text", value: "true")) - items.append(URLQueryItem(name: "tweet_mode", value: "extended")) - - guard !items.isEmpty else { return nil } - return items - } - var encodedQueryItems: [URLQueryItem]? { nil } - var formQueryItems: [URLQueryItem]? { nil } - var contentType: String? { nil } - var body: Data? { nil } - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Media+Metadata.swift b/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Media+Metadata.swift deleted file mode 100644 index 25fe83fe..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Media+Metadata.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// Twitter+API+Media+Metadata.swift -// -// -// Created by MainasuK on 2022-6-1. -// - -import Foundation -import Combine - -extension Twitter.API.Media { - public enum Metadata { } -} - -extension Twitter.API.Media.Metadata { - - private static var createEndpointURL: URL { - Twitter.API.uploadEndpointURL - .appendingPathComponent("media") - .appendingPathComponent("metadata") - .appendingPathComponent("create.json") - } - - public static func create( - session: URLSession, - query: CreateQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: createEndpointURL, - method: .POST, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - guard data.isEmpty else { - let value = try Twitter.API.decode(type: CreateResponse.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - return Twitter.Response.Content(value: .init(), response: response) - } - - public struct CreateQuery: JSONEncodeQuery { - public let mediaID: String - public let altText: AltText - - public enum CodingKeys: String, CodingKey { - case mediaID = "media_id" - case altText = "alt_text" - } - - public init( - mediaID: String, - altText: String - ) { - self.mediaID = mediaID - self.altText = .init(text: altText) - } - - public struct AltText: Codable { - public let text: String - } - - var queryItems: [URLQueryItem]? { nil } - var encodedQueryItems: [URLQueryItem]? { nil } - var formQueryItems: [URLQueryItem]? { nil } - } - - public struct CreateResponse: Codable { } - -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Media.swift b/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Media.swift deleted file mode 100644 index 64fd2d0b..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Media.swift +++ /dev/null @@ -1,275 +0,0 @@ -// -// Twitter+API+Media.swift -// -// -// Created by Cirno MainasuK on 2020-10-26. -// - -import Foundation -import Combine - -extension Twitter.API.Media { - static let uploadEndpointURL = Twitter.API.uploadEndpointURL.appendingPathComponent("media/upload.json") -} - -extension Twitter.API.Media { - - public static func `init`( - session: URLSession, - query: InitQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: uploadEndpointURL, - method: .POST, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: InitResponse.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - public struct InitQuery: Query { - public let command = "INIT" - public let totalBytes: Int - public let mediaType: String - public let mediaCategory: String - - public init( - totalBytes: Int, - mediaType: String, - mediaCategory: String - ) { - self.totalBytes = totalBytes - self.mediaType = mediaType.urlEncoded - self.mediaCategory = mediaCategory - } - - var queryItems: [URLQueryItem]? { - var items: [URLQueryItem] = [] - items.append(URLQueryItem(name: "command", value: command)) - items.append(URLQueryItem(name: "total_bytes", value: "\(totalBytes)")) - items.append(URLQueryItem(name: "media_type", value: mediaType)) - items.append(URLQueryItem(name: "media_category", value: mediaCategory)) - guard !items.isEmpty else { return nil } - return items - } - var encodedQueryItems: [URLQueryItem]? { nil } - var formQueryItems: [URLQueryItem]? { nil } - var contentType: String? { "application/x-www-form-urlencoded" } - var body: Data? { nil } - } - - public struct InitResponse: Codable { - public let mediaIDString: String - public let expiresAfterSecs: Int - - public enum CodingKeys: String, CodingKey { - case mediaIDString = "media_id_string" - case expiresAfterSecs = "expires_after_secs" - } - } - -} - -extension Twitter.API.Media { - - public static func append( - session: URLSession, - query: AppendQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - var request = Twitter.API.request( - url: uploadEndpointURL, - method: .POST, - query: query, - authorization: authorization - ) - request.timeoutInterval = 60 // should > 17 Kb/s for 1 MiB chunk - - let (data, response) = try await session.data(for: request, delegate: nil) - guard data.isEmpty else { - // try parse and throw error - do { - _ = try Twitter.API.decode(type: AppendResponse.self, from: data, response: response) - } catch { - throw error - } - // error should parsed. return empty response here for edge case - assertionFailure() - return Twitter.Response.Content(value: AppendResponse(), response: response) - } - - return Twitter.Response.Content(value: AppendResponse(), response: response) - } - - public struct AppendQuery: Query { - public let command = "APPEND" - public let mediaID: String - public let mediaData: String - public let segmentIndex: Int - - public init( - mediaID: String, - mediaData: String, - segmentIndex: Int - ) { - self.mediaID = mediaID - self.mediaData = mediaData - self.segmentIndex = segmentIndex - } - - var queryItems: [URLQueryItem]? { - var items: [URLQueryItem] = [] - items.append(URLQueryItem(name: "command", value: command)) - items.append(URLQueryItem(name: "media_id", value: mediaID)) - items.append(URLQueryItem(name: "segment_index", value: "\(segmentIndex)")) - guard !items.isEmpty else { return nil } - return items - } - var encodedQueryItems: [URLQueryItem]? { nil } - var formQueryItems: [URLQueryItem]? { - [URLQueryItem(name: "media_data", value: mediaData)] - } - var contentType: String? { "application/x-www-form-urlencoded" } - var body: Data? { - let content = "media_data=" + mediaData.urlEncoded - return content.data(using: .utf8) - } - } - - public struct AppendResponse: Codable { - // Void - } - -} - -extension Twitter.API.Media { - - public static func finalize( - session: URLSession, - query: FinalizeQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: uploadEndpointURL, - method: .POST, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: FinalizeResponse.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - public struct FinalizeQuery: Query { - public let command = "FINALIZE" - public let mediaID: String - - public init(mediaID: String) { - self.mediaID = mediaID - } - - var queryItems: [URLQueryItem]? { - var items: [URLQueryItem] = [] - items.append(URLQueryItem(name: "command", value: command)) - items.append(URLQueryItem(name: "media_id", value: mediaID)) - guard !items.isEmpty else { return nil } - return items - } - var encodedQueryItems: [URLQueryItem]? { nil } - var formQueryItems: [URLQueryItem]? { nil } - var contentType: String? { "application/x-www-form-urlencoded" } - var body: Data? { nil } - } - - public struct FinalizeResponse: Codable { - public let mediaIDString: String - public let size: Int - public let expiresAfterSecs: Int - public let processingInfo: ProcessingInfo? // server return it when media needs processing - - public enum CodingKeys: String, CodingKey { - case mediaIDString = "media_id_string" - case size - case expiresAfterSecs = "expires_after_secs" - case processingInfo = "processing_info" - } - - public struct ProcessingInfo: Codable { - public let state: String - public let checkAfterSecs: Int? - - public enum CodingKeys: String, CodingKey { - case state - case checkAfterSecs = "check_after_secs" - } - } - } - -} - -extension Twitter.API.Media { - - public static func status( - session: URLSession, - query: StatusQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: uploadEndpointURL, - method: .GET, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: StatusResponse.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - public struct StatusQuery: Query { - public let command = "STATUS" - public let mediaID: String - - public init(mediaID: String) { - self.mediaID = mediaID - } - - var queryItems: [URLQueryItem]? { - var items: [URLQueryItem] = [] - items.append(URLQueryItem(name: "command", value: command)) - items.append(URLQueryItem(name: "media_id", value: mediaID)) - guard !items.isEmpty else { return nil } - return items - } - var encodedQueryItems: [URLQueryItem]? { nil } - var formQueryItems: [URLQueryItem]? { nil } - var contentType: String? { nil } - var body: Data? { nil } - } - - public struct StatusResponse: Codable { - public let mediaIDString: String - public let expiresAfterSecs: Int - public let processingInfo: ProcessingInfo - - public enum CodingKeys: String, CodingKey { - case mediaIDString = "media_id_string" - case expiresAfterSecs = "expires_after_secs" - case processingInfo = "processing_info" - } - - public struct ProcessingInfo: Codable { - public let state: String // pending, in_progress, failed, succeeded - public let checkAfterSecs: Int? - - - public enum CodingKeys: String, CodingKey { - case state - case checkAfterSecs = "check_after_secs" - } - } - } - -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Mute.swift b/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Mute.swift deleted file mode 100644 index 305fb24c..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Mute.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// Twitter+API+Mute.swift -// -// -// Created by Cirno MainasuK on 2021-1-13. -// - -import Foundation -import Combine - -extension Twitter.API.Mute { - - static let createEndpointURL = Twitter.API.endpointURL.appendingPathComponent("mutes/users/create.json") - static let destroyEndpointURL = Twitter.API.endpointURL.appendingPathComponent("mutes/users/destroy.json") - - public static func mute(session: URLSession, authorization: Twitter.API.OAuth.Authorization, query: MuteUpdateQuery) -> AnyPublisher, Error> { - let url: URL = { - switch query.queryKind { - case .create: return createEndpointURL - case .destroy: return destroyEndpointURL - } - }() - var request = Twitter.API.request(url: url, httpMethod: "POST", authorization: authorization, queryItems: query.queryItems) - request.httpMethod = "POST" - return session.dataTaskPublisher(for: request) - .tryMap { data, response in - let value = try Twitter.API.decode(type: Twitter.Entity.User.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - .eraseToAnyPublisher() - } - -} - -extension Twitter.API.Mute { - - public struct MuteUpdateQuery { - public let userID: Twitter.Entity.User.ID - public let queryKind: QueryKind - - public enum QueryKind { - case create - case destroy - } - - public init(userID: Twitter.Entity.User.ID, queryKind: Twitter.API.Mute.MuteUpdateQuery.QueryKind) { - self.userID = userID - self.queryKind = queryKind - } - - var queryItems: [URLQueryItem]? { - var items: [URLQueryItem] = [] - items.append(URLQueryItem(name: "user_id", value: userID)) - guard !items.isEmpty else { return nil } - return items - } - } - -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+OAuth+AccessToken.swift b/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+OAuth+AccessToken.swift deleted file mode 100644 index cf00d892..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+OAuth+AccessToken.swift +++ /dev/null @@ -1,128 +0,0 @@ -// -// Twitter+API+OAuth+CustomRequestToken.swift -// -// -// Created by MainasuK on 2022-4-21. -// - -import Foundation - -extension Twitter.API.OAuth { - public enum AccessToken { } -} - -extension Twitter.API.OAuth.AccessToken { - - static let accessTokenURL = URL(string: "https://api.twitter.com/oauth/access_token")! - - public static func accessToken( - session: URLSession, - query: AccessTokenQuery - ) async throws -> AccessTokenResponse { - let request = accessTokenURLRequest( - consumerKey: query.consumerKey, - consumerSecret: query.consumerSecret, - requestToken: query.requestToken, - pinCode: query.pinCode - ) - - let (data, _) = try await session.data(for: request, delegate: nil) - guard let body = String(data: data, encoding: .utf8), - let accessTokenResponse = AccessTokenResponse(urlEncodedForm: body) - else { - throw Twitter.API.Error.InternalError(message: "process requestToken response fail") - } - - return accessTokenResponse - } - - static func accessTokenURLRequest( - consumerKey: String, - consumerSecret: String, - requestToken: String, - pinCode: String - ) -> URLRequest { - var components = URLComponents(string: accessTokenURL.absoluteString)! - let queryItems = [ - URLQueryItem(name: "oauth_token", value: requestToken), - URLQueryItem(name: "oauth_verifier", value: pinCode) - ] - components.queryItems = queryItems - let requestURL = components.url! - var request = URLRequest( - url: requestURL, - cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, - timeoutInterval: Twitter.API.timeoutInterval - ) - request.httpMethod = "POST" - let authorizationHeader = Twitter.API.OAuth.authorizationHeader( - requestURL: requestURL, - requestFormQueryItems: queryItems, - httpMethod: "POST", - callbackURL: nil, - consumerKey: consumerKey, - consumerSecret: consumerSecret, - oauthToken: requestToken, - oauthTokenSecret: nil - ) - request.setValue(authorizationHeader, forHTTPHeaderField: Twitter.API.OAuth.authorizationField) - return request - } - - public struct AccessTokenQuery { - public let consumerKey: String - public let consumerSecret: String - public let requestToken: String - public let pinCode: String - - public init( - consumerKey: String, - consumerSecret: String, - requestToken: String, - pinCode: String - ) { - self.consumerKey = consumerKey - self.consumerSecret = consumerSecret - self.requestToken = requestToken - self.pinCode = pinCode - } - } - - public struct AccessTokenResponse: Codable { - public let oauthToken: String - public let oauthTokenSecret: String - public let userID: String - public let screenName: String - - enum CodingKeys: String, CodingKey, CaseIterable { - case oauthToken = "oauth_token" - case oauthTokenSecret = "oauth_token_secret" - case userID = "user_id" - case screenName = "screen_name" - } - - init?(urlEncodedForm form: String) { - var dict: [String: String] = [:] - for component in form.components(separatedBy: "&") { - let tuple = component.components(separatedBy: "=") - for key in CodingKeys.allCases { - if tuple[0] == key.rawValue { dict[key.rawValue] = tuple[1] } - } - } - - guard let oauthToken = dict[CodingKeys.oauthToken.rawValue], - let oauthTokenSecret = dict[CodingKeys.oauthTokenSecret.rawValue], - let userID = dict[CodingKeys.userID.rawValue], - let screenName = dict[CodingKeys.screenName.rawValue] else - { - return nil - } - - self.oauthToken = oauthToken - self.oauthTokenSecret = oauthTokenSecret - self.userID = userID - self.screenName = screenName - } - } - -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+OAuth+RequestToken.swift b/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+OAuth+RequestToken.swift deleted file mode 100644 index c2a2dd18..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+OAuth+RequestToken.swift +++ /dev/null @@ -1,301 +0,0 @@ -// -// Twitter+API+OAuth+RequestToken.swift -// -// -// Created by MainasuK on 2022-4-21. -// - -import os.log -import Foundation -import CryptoKit - -extension Twitter.API.OAuth { - public enum RequestToken { - public enum Standard { } - public enum Relay { } - } -} - -extension Twitter.API.OAuth.RequestToken.Standard { - - public static func requestToken( - session: URLSession, - query: Query - ) async throws -> Response { - let request = requestTokenURLRequest( - consumerKey: query.consumerKey, - consumerKeySecret: query.consumerKeySecret - ) - let (data, _) = try await session.data(for: request, delegate: nil) - let templateURLString = Twitter.API.OAuth.requestTokenEndpointURL.absoluteString - guard let body = String(data: data, encoding: .utf8), - let components = URLComponents(string: templateURLString + "?" + body), - let requestTokenResponse = Response(queryItems: components.queryItems ?? []) - else { - throw Twitter.API.Error.InternalError(message: "process requestToken response fail") - } - return requestTokenResponse - } - - public struct Query { - public let consumerKey: String - public let consumerKeySecret: String - - public init(consumerKey: String, consumerKeySecret: String) { - self.consumerKey = consumerKey - self.consumerKeySecret = consumerKeySecret - } - } - - public struct Response: Codable, CustomDebugStringConvertible { - public let oauthToken: String - public let oauthTokenSecret: String - public let oauthCallbackConfirmed: Bool - - public enum CodingKeys: String, CodingKey { - case oauthToken = "oauth_token" - case oauthTokenSecret = "oauth_token_secret" - case oauthCallbackConfirmed = "oauth_callback_confirmed" - } - - init?(queryItems: [URLQueryItem]) { - var _oauthToken: String? - var _oauthTokenSecret: String? - var _oauthCallbackConfirmed: Bool? - for item in queryItems { - switch item.name { - case "oauth_token": _oauthToken = item.value - case "oauth_token_secret": _oauthTokenSecret = item.value - case "oauth_callback_confirmed": _oauthCallbackConfirmed = item.value == "true" - default: continue - } - } - - guard let oauthToken = _oauthToken, - let oauthTokenSecret = _oauthTokenSecret, - let oauthCallbackConfirmed = _oauthCallbackConfirmed else { - return nil - } - - self.oauthToken = oauthToken - self.oauthTokenSecret = oauthTokenSecret - self.oauthCallbackConfirmed = oauthCallbackConfirmed - } - - public var debugDescription: String { - """ - - oauth_token: \(oauthToken) - - oauth_token_secret: \(oauthTokenSecret) - - oauth_callback_confirmed: \(oauthCallbackConfirmed) - """ - } - } - - static func requestTokenURLRequest( - consumerKey: String, - consumerKeySecret: String - ) -> URLRequest { - var components = URLComponents(string: Twitter.API.OAuth.requestTokenEndpointURL.absoluteString)! - let queryItems = [URLQueryItem(name: "oauth_callback", value: "oob")] - components.queryItems = queryItems - let requestURL = components.url! - var request = URLRequest( - url: requestURL, - cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, - timeoutInterval: Twitter.API.timeoutInterval - ) - request.httpMethod = "POST" - let authorizationHeader = Twitter.API.OAuth.authorizationHeader( - requestURL: requestURL, - requestFormQueryItems: queryItems, - httpMethod: "POST", - callbackURL: nil, - consumerKey: consumerKey, - consumerSecret: consumerKeySecret, - oauthToken: nil, - oauthTokenSecret: nil - ) - request.setValue(authorizationHeader, forHTTPHeaderField: Twitter.API.OAuth.authorizationField) - return request - } - -} - -extension Twitter.API.OAuth.RequestToken.Relay { - - public static func requestToken( - session: URLSession, - query: Query - ) async throws -> Response { - let consumerKey = query.consumerKey - let hostPublicKey = query.hostPublicKey - let oauthEndpoint = query.oauthEndpoint - os_log("%{public}s[%{public}ld], %{public}s: request token %s", ((#file as NSString).lastPathComponent), #line, #function, oauthEndpoint) - - let clientEphemeralPrivateKey = Curve25519.KeyAgreement.PrivateKey() - let clientEphemeralPublicKey = clientEphemeralPrivateKey.publicKey - do { - let sharedSecret = try clientEphemeralPrivateKey.sharedSecretFromKeyAgreement(with: hostPublicKey) - let salt = clientEphemeralPublicKey.rawRepresentation + sharedSecret.withUnsafeBytes { Data($0) } - let wrapKey = sharedSecret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: salt, sharedInfo: Data("request token exchange".utf8), outputByteCount: 32) - let consumerKeyBox = try ChaChaPoly.seal(Data(consumerKey.utf8), using: wrapKey) - let customRequestTokenPayload = Payload(exchangePublicKey: clientEphemeralPublicKey, consumerKeyBox: consumerKeyBox) - - var request = URLRequest(url: URL(string: oauthEndpoint + "/oauth/request_token")!, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, timeoutInterval: Twitter.API.timeoutInterval) - request.httpMethod = "POST" - request.addValue("application/json; charset=UTF-8", forHTTPHeaderField: "Content-Type") - request.httpBody = try JSONEncoder().encode(customRequestTokenPayload) - - let (data, _) = try await session.data(for: request, delegate: nil) - os_log("%{public}s[%{public}ld], %{public}s: request token response data: %s", ((#file as NSString).lastPathComponent), #line, #function, String(data: data, encoding: .utf8) ?? "") - let response = try JSONDecoder().decode(Response.Content.self, from: data) - os_log("%{public}s[%{public}ld], %{public}s: request token response: %s", ((#file as NSString).lastPathComponent), #line, #function, String(describing: response)) - - guard let exchangePublicKeyData = Data(base64Encoded: response.exchangePublicKey), - let exchangePublicKey = try? Curve25519.KeyAgreement.PublicKey(rawRepresentation: exchangePublicKeyData), - let sharedSecret = try? clientEphemeralPrivateKey.sharedSecretFromKeyAgreement(with: exchangePublicKey), - let combinedData = Data(base64Encoded: response.requestTokenBox) else - { - throw Twitter.API.Error.InternalError(message: "invalid requestToken response") - } - do { - let salt = exchangePublicKey.rawRepresentation + sharedSecret.withUnsafeBytes { Data($0) } - let wrapKey = sharedSecret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: salt, sharedInfo: Data("request token response exchange".utf8), outputByteCount: 32) - let sealedBox = try ChaChaPoly.SealedBox(combined: combinedData) - let requestTokenData = try ChaChaPoly.open(sealedBox, using: wrapKey) - guard let requestToken = String(data: requestTokenData, encoding: .utf8) else { - throw Twitter.API.Error.InternalError(message: "invalid requestToken response") - } - let append = Response.Append( - requestToken: requestToken, - clientExchangePrivateKey: clientEphemeralPrivateKey, - hostExchangePublicKey: exchangePublicKey - ) - return Response( - content: response, - append: append - ) - } catch { - assertionFailure(error.localizedDescription) - throw Twitter.API.Error.InternalError(message: "process requestToken response fail") - } - } catch { - assertionFailure(error.localizedDescription) - throw error - } - } - - public struct Query { - public let consumerKey: String - public let hostPublicKey: Curve25519.KeyAgreement.PublicKey - public let oauthEndpoint: String - - public init(consumerKey: String, hostPublicKey: Curve25519.KeyAgreement.PublicKey, oauthEndpoint: String) { - self.consumerKey = consumerKey - self.hostPublicKey = hostPublicKey - self.oauthEndpoint = oauthEndpoint - } - } - - public struct Response { - public let content: Content - public let append: Append - - public struct Content: Codable { - public let exchangePublicKey: String - public let requestTokenBox: String - - enum CodingKeys: String, CodingKey, CaseIterable { - case exchangePublicKey = "exchange_public_key" - case requestTokenBox = "request_token_box" - } - } - - public struct Append { - public let requestToken: String - public let clientExchangePrivateKey: Curve25519.KeyAgreement.PrivateKey - public let hostExchangePublicKey: Curve25519.KeyAgreement.PublicKey - } - } - - struct Payload: Codable { - public let exchangePublicKey: String - public let consumerKeyBox: String - - public enum CodingKeys: String, CodingKey { - case exchangePublicKey = "exchange_public_key" - case consumerKeyBox = "consumer_key_box" - } - - init(exchangePublicKey: Curve25519.KeyAgreement.PublicKey, consumerKeyBox: ChaChaPoly.SealedBox) { - self.exchangePublicKey = exchangePublicKey.rawRepresentation.base64EncodedString() - self.consumerKeyBox = consumerKeyBox.combined.base64EncodedString() - } - } - - public struct OAuthCallback: Codable { - let exchangePublicKey: String - let authenticationBox: String - - enum CodingKeys: String, CodingKey, CaseIterable { - case exchangePublicKey = "exchange_public_key" - case authenticationBox = "authentication_box" - } - - public init?(callbackURL url: URL) { - guard let urlComponents = URLComponents(string: url.absoluteString) else { return nil } - guard let queryItems = urlComponents.queryItems, - let exchangePublicKey = queryItems.first(where: { $0.name == CodingKeys.exchangePublicKey.rawValue })?.value, - let authenticationBox = queryItems.first(where: { $0.name == CodingKeys.authenticationBox.rawValue })?.value else - { - return nil - } - self.exchangePublicKey = exchangePublicKey - self.authenticationBox = authenticationBox - } - - public func authentication(privateKey: Curve25519.KeyAgreement.PrivateKey) throws -> Authentication { - do { - guard let exchangePublicKeyData = Data(base64Encoded: exchangePublicKey), - let sealedBoxData = Data(base64Encoded: authenticationBox) else { - throw Twitter.API.Error.InternalError(message: "invalid callback") - } - let exchangePublicKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: exchangePublicKeyData) - let sharedSecret = try privateKey.sharedSecretFromKeyAgreement(with: exchangePublicKey) - let salt = exchangePublicKey.rawRepresentation + sharedSecret.withUnsafeBytes { Data($0) } - let wrapKey = sharedSecret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: salt, sharedInfo: Data("authentication exchange".utf8), outputByteCount: 32) - let sealedBox = try ChaChaPoly.SealedBox(combined: sealedBoxData) - - let authenticationData = try ChaChaPoly.open(sealedBox, using: wrapKey) - let authentication = try JSONDecoder().decode(Authentication.self, from: authenticationData) - return authentication - - } catch { - if let error = error as? Twitter.API.Error.ResponseError { - throw error - } else { - throw Twitter.API.Error.InternalError(message: error.localizedDescription) - } - } - } - } - - public struct Authentication: Codable { - public let accessToken: String - public let accessTokenSecret: String - public let userID: String - public let screenName: String - public let consumerKey: String - public let consumerSecret: String - - public enum CodingKeys: String, CodingKey { - case accessToken = "access_token" - case accessTokenSecret = "access_token_secret" - case userID = "uesr_id" // server typo and need keep it - case screenName = "screen_name" - case consumerKey = "consumer_key" - case consumerSecret = "consumer_secret" - } - } - -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+OAuth.swift b/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+OAuth.swift deleted file mode 100644 index 7e738ba0..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+OAuth.swift +++ /dev/null @@ -1,195 +0,0 @@ -// -// Twitter+API+OAuth.swift -// TwitterAPI -// -// Created by Cirno MainasuK on 2020-9-1. -// - -import os.log -import Foundation -import Combine -import CryptoKit - -extension Twitter.API.OAuth { - - static let requestTokenEndpointURL = URL(string: "https://api.twitter.com/oauth/request_token")! - static let authorizeEndpointURL = URL(string: "https://api.twitter.com/oauth/authorize")! - -} - -extension Twitter.API.OAuth { - -// public static func requestToken( -// session: URLSession, -// query: Twitter.API.OAuth.RequestTokenQueryContext -// ) async throws -> Twitter.API.OAuth.RequestTokenResponseContext { -// switch query { -// case .standard(let query): -// let response = try await Twitter.API.OAuth.RequestToken.Standard.requestToken( -// session: session, -// query: query -// ) -// return .standard(response) -// case .relay(let query): -// let response = try await Twitter.API.OAuth.RequestToken.Relay.requestToken( -// session: session, -// query: query -// ) -// return .relay(response) -// } -// } -// -// public enum RequestTokenQueryContext { -// case standard(query: Twitter.API.OAuth.RequestToken.Standard.Query) -// case relay(query: Twitter.API.OAuth.RequestToken.Relay.Query) -// } -// -// public enum RequestTokenResponseContext { -// case standard(Twitter.API.OAuth.RequestToken.Standard.Response) -// case relay(Twitter.API.OAuth.RequestToken.Relay.Response) -// } - -} - -extension Twitter.API.OAuth { - - static var authorizationField = "Authorization" - - static func authorizationHeader( - requestURL url: URL, - requestFormQueryItems formQueryItems: [URLQueryItem]?, - httpMethod: String, - callbackURL: URL?, - consumerKey: String, - consumerSecret: String, - oauthToken: String?, - oauthTokenSecret: String? - ) -> String { - var authorizationParameters = Dictionary() - authorizationParameters["oauth_version"] = "1.0" - authorizationParameters["oauth_callback"] = callbackURL?.absoluteString - authorizationParameters["oauth_consumer_key"] = consumerKey - authorizationParameters["oauth_signature_method"] = "HMAC-SHA1" - authorizationParameters["oauth_timestamp"] = String(Int(Date().timeIntervalSince1970)) - authorizationParameters["oauth_nonce"] = UUID().uuidString - - authorizationParameters["oauth_token"] = oauthToken - - authorizationParameters["oauth_signature"] = oauthSignature( - requestURL: url, - requestFormQueryItems: formQueryItems, - httpMethod: httpMethod, - consumerSecret: consumerSecret, - parameters: authorizationParameters, - oauthTokenSecret: oauthTokenSecret - ) - - var parameterComponents = authorizationParameters.urlEncodedQuery.components(separatedBy: "&") as [String] - parameterComponents.sort { $0 < $1 } - - var headerComponents = [String]() - for component in parameterComponents { - let subcomponent = component.components(separatedBy: "=") as [String] - if subcomponent.count == 2 { - headerComponents.append("\(subcomponent[0])=\"\(subcomponent[1])\"") - } - } - - return "OAuth " + headerComponents.joined(separator: ", ") - } - - static func oauthSignature( - requestURL url: URL, - requestFormQueryItems - formQueryItems: [URLQueryItem]?, - httpMethod: String, - consumerSecret: String, - parameters: Dictionary, - oauthTokenSecret: String? - ) -> String { - let encodedConsumerSecret = consumerSecret.urlEncoded - let encodedTokenSecret = oauthTokenSecret?.urlEncoded ?? "" - let signingKey = "\(encodedConsumerSecret)&\(encodedTokenSecret)" - - var parameters = parameters - - var components = URLComponents(string: url.absoluteString)! - for item in components.queryItems ?? [] { - parameters[item.name] = item.value - } - for item in formQueryItems ?? [] { - parameters[item.name] = item.value - } - - components.queryItems = nil - let baseURL = components.url! - - var parameterComponents = parameters.urlEncodedQuery.components(separatedBy: "&") - parameterComponents.sort { - let p0 = $0.components(separatedBy: "=") - let p1 = $1.components(separatedBy: "=") - if p0.first == p1.first { return p0.last ?? "" < p1.last ?? "" } - return p0.first ?? "" < p1.first ?? "" - } - - let parameterString = parameterComponents.joined(separator: "&") - let encodedParameterString = parameterString.urlEncoded - - let encodedURL = baseURL.absoluteString.urlEncoded - - let signatureBaseString = "\(httpMethod)&\(encodedURL)&\(encodedParameterString)" - let message = Data(signatureBaseString.utf8) - - let key = SymmetricKey(data: Data(signingKey.utf8)) - var hmac: HMAC = HMAC(key: key) - hmac.update(data: message) - let mac = hmac.finalize() - - let base64EncodedMac = Data(mac).base64EncodedString() - return base64EncodedMac - } - -} - -extension Twitter.API.OAuth { - - public struct Authorization: Hashable { - public let consumerKey: String - public let consumerSecret: String - public let accessToken: String - public let accessTokenSecret: String - - public init(consumerKey: String, consumerSecret: String, accessToken: String, accessTokenSecret: String) { - self.consumerKey = consumerKey - self.consumerSecret = consumerSecret - self.accessToken = accessToken - self.accessTokenSecret = accessTokenSecret - } - - func authorizationHeader(requestURL url: URL, requestFormQueryItems: [URLQueryItem]? = nil, httpMethod: String) -> String { - return Twitter.API.OAuth.authorizationHeader( - requestURL: url, - requestFormQueryItems: requestFormQueryItems, - httpMethod: httpMethod, - callbackURL: nil, - consumerKey: consumerKey, - consumerSecret: consumerSecret, - oauthToken: accessToken, - oauthTokenSecret: accessTokenSecret - ) - } - } - -} - -extension Twitter.API.OAuth { - - public static func authorizeURL(requestToken: String) -> URL { - var urlComponents = URLComponents(string: authorizeEndpointURL.absoluteString)! - urlComponents.queryItems = [ - URLQueryItem(name: "oauth_token", value: requestToken), - ] - return urlComponents.url! - } - -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+SavedSearch.swift b/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+SavedSearch.swift deleted file mode 100644 index 0b09e76d..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+SavedSearch.swift +++ /dev/null @@ -1,104 +0,0 @@ -// -// Twitter+API+SavedSearch.swift -// -// -// Created by MainasuK on 2021-12-22. -// - -import Foundation - -extension Twitter.API.SavedSearch { - - static let listEndpointURL = Twitter.API.endpointURL - .appendingPathComponent("saved_searches") - .appendingPathComponent("list.json") - - // Returns the authenticated user's saved search queries. - // doc: https://developer.twitter.com/en/docs/twitter-api/v1/accounts-and-users/manage-account-settings/api-reference/get-saved_searches-list - public static func list( - session: URLSession, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content<[Twitter.Entity.SavedSearch]> { - let request = Twitter.API.request( - url: listEndpointURL, - method: .GET, - query: nil, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: [Twitter.Entity.SavedSearch].self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - -} - -extension Twitter.API.SavedSearch { - - static let createEndpointURL = Twitter.API.endpointURL - .appendingPathComponent("saved_searches") - .appendingPathComponent("create.json") - - // Create a new saved search for the authenticated user. A user may only have 25 saved searches. - // doc: https://developer.twitter.com/en/docs/twitter-api/v1/accounts-and-users/manage-account-settings/api-reference/post-saved_searches-create - public static func create( - session: URLSession, - query: CreateQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: createEndpointURL, - method: .POST, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: Twitter.Entity.SavedSearch.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - public struct CreateQuery: Query { - public let query: String - - public init(query: String) { - self.query = query - } - - var queryItems: [URLQueryItem]? { - [URLQueryItem(name: "query", value: query)] - } - var encodedQueryItems: [URLQueryItem]? { nil } - var formQueryItems: [URLQueryItem]? { nil } - var contentType: String? { nil } - var body: Data? { nil } - } - -} - -extension Twitter.API.SavedSearch { - - static func destroyEndpointURL(id: Twitter.Entity.SavedSearch.ID) -> URL { - Twitter.API.endpointURL - .appendingPathComponent("saved_searches") - .appendingPathComponent("destroy") - .appendingPathComponent("\(id).json") - } - - // Destroys a saved search for the authenticating user. The authenticating user must be the owner of saved search id being destroyed. - // doc: https://developer.twitter.com/en/docs/twitter-api/v1/accounts-and-users/manage-account-settings/api-reference/post-saved_searches-destroy-id - public static func destroy( - session: URLSession, - id: Twitter.Entity.SavedSearch.ID, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: destroyEndpointURL(id: id), - method: .POST, - query: nil, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: Twitter.Entity.SavedSearch.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Search.swift b/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Search.swift deleted file mode 100644 index e9626969..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Search.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// Twitter+API+Search.swift -// -// -// Created by Cirno MainasuK on 2021-1-20. -// - -import Foundation -import Combine - -extension Twitter.API.Search { - - static var tweetsEndpointURL = Twitter.API.endpointURL.appendingPathComponent("search/tweets.json") - - public static func tweets( - session: URLSession, - query: Twitter.API.Statuses.Timeline.TimelineQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: tweetsEndpointURL, - method: .GET, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: Twitter.API.Search.Content.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - -} - -extension Twitter.API.Search { - public class Content: Codable { - - public let statuses: [Twitter.Entity.Tweet]? - public let searchMetadata: SearchMetadata - - public enum CodingKeys: String, CodingKey { - case statuses - case searchMetadata = "search_metadata" - } - - public struct SearchMetadata: Codable { - public let nextResults: String? - public let query: String - public let count: Int - - public enum CodingKeys: String, CodingKey { - case nextResults = "next_results" - case query - case count - } - } - - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Statuses+Timeline.swift b/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Statuses+Timeline.swift deleted file mode 100644 index dc56aa04..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Statuses+Timeline.swift +++ /dev/null @@ -1,165 +0,0 @@ -// -// Twitter+API+Statuses+Timeline.swift -// -// -// Created by MainasuK on 2021/11/11. -// - -import Foundation - -public protocol TimelineQueryType { - var maxID: Twitter.Entity.Tweet.ID? { get } - var sinceID: Twitter.Entity.Tweet.ID? { get } -} - -extension Twitter.API.Statuses.Timeline { - public struct TimelineQuery: TimelineQueryType, Query { - - // share - public let count: Int? - public let maxID: Twitter.Entity.Tweet.ID? - public let sinceID: Twitter.Entity.Tweet.ID? - public let excludeReplies: Bool? - - // user timeline - public let userID: Twitter.Entity.User.ID? - - // search - public let query: String? - - public init( - count: Int? = nil, - userID: Twitter.Entity.User.ID? = nil, - maxID: Twitter.Entity.Tweet.ID? = nil, - sinceID: Twitter.Entity.Tweet.ID? = nil, - excludeReplies: Bool? = nil, - query: String? = nil - ) { - self.count = count - self.userID = userID - self.maxID = maxID - self.sinceID = sinceID - self.excludeReplies = excludeReplies - self.query = query - } - - var queryItems: [URLQueryItem]? { - var items: [URLQueryItem] = [] - if let count = self.count { - items.append(URLQueryItem(name: "count", value: String(count))) - } - if let userID = self.userID { - items.append(URLQueryItem(name: "user_id", value: userID)) - } - if let maxID = self.maxID { - items.append(URLQueryItem(name: "max_id", value: maxID)) - } - if let sinceID = self.sinceID { - items.append(URLQueryItem(name: "since_id", value: sinceID)) - } - if let excludeReplies = self.excludeReplies { - items.append(URLQueryItem(name: "exclude_replies", value: excludeReplies ? "true" : "false")) - } - items.append(URLQueryItem(name: "include_ext_alt_text", value: "true")) - items.append(URLQueryItem(name: "tweet_mode", value: "extended")) - - guard !items.isEmpty else { return nil } - return items - } - var encodedQueryItems: [URLQueryItem]? { - var items: [URLQueryItem] = [] - if let query = query { - items.append(URLQueryItem(name: "q", value: query.urlEncoded)) - } - guard !items.isEmpty else { return nil } - return items - } - var formQueryItems: [URLQueryItem]? { nil } - var contentType: String? { nil } - var body: Data? { nil } - - } -} - -extension Twitter.API.Statuses.Timeline { - - static let homeTimelineEndpointURL = Twitter.API.endpointURL.appendingPathComponent("statuses/home_timeline.json") - - public static func home( - session: URLSession, - query: TimelineQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content<[Twitter.Entity.Tweet]> { - let request = Twitter.API.request( - url: homeTimelineEndpointURL, - method: .GET, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - do { - let value = try Twitter.API.decode(type: [Twitter.Entity.Tweet].self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } catch { - debugPrint(error) - throw error - } - } - -} - -extension Twitter.API.Statuses.Timeline { - - static let userTimelineEndpointURL = Twitter.API.endpointURL.appendingPathComponent("statuses/user_timeline.json") - - public static func user( - session: URLSession, - query: TimelineQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content<[Twitter.Entity.Tweet]> { - let request = Twitter.API.request( - url: userTimelineEndpointURL, - method: .GET, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - do { - let value = try Twitter.API.decode(type: [Twitter.Entity.Tweet].self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } catch { - debugPrint(error) - throw error - } - } - -} - -extension Twitter.API.Statuses.Timeline { - - static let mentionTimelineEndpointURL = Twitter.API.endpointURL.appendingPathComponent("statuses/mentions_timeline.json") - - public static func mentions( - session: URLSession, - query: TimelineQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content<[Twitter.Entity.Tweet]> { - let request = Twitter.API.request( - url: mentionTimelineEndpointURL, - method: .GET, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - do { - let value = try Twitter.API.decode(type: [Twitter.Entity.Tweet].self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } catch { - debugPrint(error) - throw error - } - } - -} - - diff --git a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Statuses.swift b/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Statuses.swift deleted file mode 100644 index 42c68226..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Statuses.swift +++ /dev/null @@ -1,162 +0,0 @@ -// -// Twitter+API+Statuses.swift -// -// -// Created by Cirno MainasuK on 2020-10-15. -// - -import Foundation -import Combine - -extension Twitter.API.Statuses { - - static let updateEndpointURL = Twitter.API.endpointURL - .appendingPathComponent("statuses") - .appendingPathComponent("update.json") - - public static func update( - session: URLSession, - query: UpdateQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: updateEndpointURL, - method: .POST, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: Twitter.Entity.Tweet.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - public struct UpdateQuery: Query { - public let status: String - public let inReplyToStatusID: Twitter.Entity.Tweet.ID? - public let autoPopulateReplyMetadata: Bool? - public let excludeReplyUserIDs: String? - public let mediaIDs: String? - public let latitude: Double? - public let longitude: Double? - public let placeID: String? - - public init( - status: String, - inReplyToStatusID: Twitter.Entity.Tweet.ID?, - autoPopulateReplyMetadata: Bool?, - excludeReplyUserIDs: String?, - mediaIDs: String?, - latitude: Double?, - longitude: Double?, - placeID: String? - ) { - self.status = status - self.inReplyToStatusID = inReplyToStatusID - self.autoPopulateReplyMetadata = autoPopulateReplyMetadata - self.excludeReplyUserIDs = excludeReplyUserIDs - self.mediaIDs = mediaIDs - self.latitude = latitude - self.longitude = longitude - self.placeID = placeID - } - - var queryItems: [URLQueryItem]? { - var items: [URLQueryItem] = [] - inReplyToStatusID.flatMap { items.append(URLQueryItem(name: "in_reply_to_status_id", value: $0)) } - autoPopulateReplyMetadata.flatMap { items.append(URLQueryItem(name: "auto_populate_reply_metadata", value: $0 ? "true" : "false")) } - excludeReplyUserIDs.flatMap { items.append(URLQueryItem(name: "exclude_reply_user_ids", value: $0)) } - mediaIDs.flatMap { items.append(URLQueryItem(name: "media_ids", value: $0)) } - latitude.flatMap { items.append(URLQueryItem(name: "lat", value: String($0))) } - longitude.flatMap { items.append(URLQueryItem(name: "long", value: String($0))) } - placeID.flatMap { items.append(URLQueryItem(name: "place_id", value: $0)) } - guard !items.isEmpty else { return nil } - return items - } - var encodedQueryItems: [URLQueryItem]? { - var items: [URLQueryItem] = [] - items.append(URLQueryItem(name: "status", value: status.urlEncoded)) - guard !items.isEmpty else { return nil } - return items - } - var formQueryItems: [URLQueryItem]? { nil } - var contentType: String? { nil } - var body: Data? { nil } - } - -} - -extension Twitter.API.Statuses { - - static func retweetEndpointURL(tweetID: Twitter.Entity.Tweet.ID) -> URL { return Twitter.API.endpointURL.appendingPathComponent("statuses/retweet/\(tweetID).json") } - static func unretweetEndpointURL(tweetID: Twitter.Entity.Tweet.ID) -> URL { return Twitter.API.endpointURL.appendingPathComponent("statuses/unretweet/\(tweetID).json") } - static func destroyEndpointURL(tweetID: Twitter.Entity.Tweet.ID) -> URL { return Twitter.API.endpointURL.appendingPathComponent("statuses/destroy/\(tweetID).json") } - - public static func retweet(session: URLSession, authorization: Twitter.API.OAuth.Authorization, retweetKind: RetweetKind, query: RetweetQuery) -> AnyPublisher, Error> { - let url: URL = { - switch retweetKind { - case .retweet: return retweetEndpointURL(tweetID: query.id) - case .unretweet: return unretweetEndpointURL(tweetID: query.id) - } - }() - var request = Twitter.API.request(url: url, httpMethod: "POST", authorization: authorization, queryItems: query.queryItems) - request.httpMethod = "POST" - return session.dataTaskPublisher(for: request) - .tryMap { data, response in - let value = try Twitter.API.decode(type: Twitter.Entity.Tweet.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - .eraseToAnyPublisher() - } - - public static func destroy(session: URLSession, authorization: Twitter.API.OAuth.Authorization, query: DestroyQuery) -> AnyPublisher, Error> { - let url = destroyEndpointURL(tweetID: query.id) - var request = Twitter.API.request(url: url, httpMethod: "POST", authorization: authorization, queryItems: query.queryItems) - request.httpMethod = "POST" - return session.dataTaskPublisher(for: request) - .tryMap { data, response in - let value = try Twitter.API.decode(type: Twitter.Entity.Tweet.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - .eraseToAnyPublisher() - } - -} - -extension Twitter.API.Statuses { - - public enum RetweetKind { - case retweet - case unretweet - } - - public struct RetweetQuery { - public let id: Twitter.Entity.Tweet.ID - - public init(id: Twitter.Entity.Tweet.ID) { - self.id = id - } - - var queryItems: [URLQueryItem]? { - var items: [URLQueryItem] = [] - items.append(URLQueryItem(name: "id", value: id)) - guard !items.isEmpty else { return nil } - return items - } - } - - public struct DestroyQuery { - public let id: Twitter.Entity.Tweet.ID - - public init(id: Twitter.Entity.Tweet.ID) { - self.id = id - } - - var queryItems: [URLQueryItem]? { - var items: [URLQueryItem] = [] - items.append(URLQueryItem(name: "id", value: id)) - guard !items.isEmpty else { return nil } - return items - } - } - -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Trend.swift b/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Trend.swift deleted file mode 100644 index c4084d78..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Trend.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// Twitter+API+Trend.swift -// -// -// Created by MainasuK on 2021-12-28. -// - -import Foundation - -extension Twitter.API.Trend { - - // https://developer.twitter.com/en/docs/twitter-api/v1/trends/trends-for-location/api-reference/get-trends-place - static let trendsTopicEndpointURL = Twitter.API.endpointURL - .appendingPathComponent("trends") - .appendingPathComponent("place.json") - - public static func topics( - session: URLSession, - query: TopicQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content<[TopicResponse]> { - let request = Twitter.API.request( - url: trendsTopicEndpointURL, - method: .GET, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: [TopicResponse].self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - public struct TopicQuery: Query { - public let id: Int - - public init(id: Int) { - self.id = id - } - - var queryItems: [URLQueryItem]? { - var items: [URLQueryItem] = [] - items.append(URLQueryItem(name: "id", value: "\(id)")) - guard !items.isEmpty else { return nil } - return items - } - var encodedQueryItems: [URLQueryItem]? { nil } - var formQueryItems: [URLQueryItem]? { nil } - var contentType: String? { nil } - var body: Data? { nil } - } - - public struct TopicResponse: Codable, Hashable { - public let trends: [Twitter.Entity.Trend] - public let asOf: Date - public let createdAt: Date - public let locations: [Location] - - enum CodingKeys: String, CodingKey { - case trends - case asOf = "as_of" - case createdAt = "created_at" - case locations - } - - public struct Location: Codable, Hashable { - public let name: String - public let woeid: Int - } - } - -} - -extension Twitter.API.Trend { - - // https://developer.twitter.com/en/docs/twitter-api/v1/trends/locations-with-trending-topics/api-reference/get-trends-available - static let trendsPlaceEndpointURL = Twitter.API.endpointURL - .appendingPathComponent("trends") - .appendingPathComponent("available.json") - - public static func places( - session: URLSession, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content<[Twitter.Entity.Trend.Place]> { - let request = Twitter.API.request( - url: trendsPlaceEndpointURL, - method: .GET, - query: nil, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: [Twitter.Entity.Trend.Place].self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Users+Search.swift b/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Users+Search.swift deleted file mode 100644 index 806d15aa..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Users+Search.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// Twitter+API+Users+Search.swift -// -// -// Created by Cirno MainasuK on 2021-10-25. -// - -import Foundation - -extension Twitter.API.Users { - - static let searchEndpointURL = Twitter.API.endpointURL.appendingPathComponent("users/search.json") - - public static func search( - session: URLSession, - query: SearchQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content<[Twitter.Entity.User]> { - let request = Twitter.API.request( - url: searchEndpointURL, - method: .GET, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: [Twitter.Entity.User].self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - public struct SearchQuery: Query { - public let q: String - public let page: Int - public let count: Int - - public init( - q: String, - page: Int, - count: Int - ) { - self.q = q - self.page = page - self.count = min(count, 20) - } - - var queryItems: [URLQueryItem]? { - var items: [URLQueryItem] = [] - items.append(URLQueryItem(name: "q", value: q)) - items.append(URLQueryItem(name: "page", value: "\(page)")) - items.append(URLQueryItem(name: "count", value: "\(count)")) - guard !items.isEmpty else { return nil } - return items - } - var encodedQueryItems: [URLQueryItem]? { nil } - var formQueryItems: [URLQueryItem]? { nil } - var contentType: String? { nil } - var body: Data? { nil } - } - -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Users.swift b/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Users.swift deleted file mode 100644 index 89962914..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API+Users.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// Twitter+API+Users.swift -// -// -// Created by Cirno MainasuK on 2020-10-30. -// - -import Foundation -import Combine - -extension Twitter.API.Users { - - static let reportSpamEndpointURL = Twitter.API.endpointURL.appendingPathComponent("users/report_spam.json") - - public static func reportSpam( - session: URLSession, - query: ReportSpamQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: reportSpamEndpointURL, - method: .POST, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: Twitter.Entity.User.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - public struct ReportSpamQuery: Query { - public let userID: Twitter.Entity.User.ID - public let performBlock: Bool - - public init(userID: Twitter.Entity.User.ID, performBlock: Bool) { - self.userID = userID - self.performBlock = performBlock - } - - var queryItems: [URLQueryItem]? { - var items: [URLQueryItem] = [] - items.append(URLQueryItem(name: "user_id", value: userID)) - items.append(URLQueryItem(name: "perform_block", value: performBlock ? "true" : "false")) - guard !items.isEmpty else { return nil } - return items - } - var encodedQueryItems: [URLQueryItem]? { nil } - var formQueryItems: [URLQueryItem]? { nil } - var contentType: String? { nil } - var body: Data? { nil } - } - -} - diff --git a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API.swift b/TwidereSDK/Sources/TwitterSDK/API/Twitter+API.swift deleted file mode 100644 index 5848150c..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/Twitter+API.swift +++ /dev/null @@ -1,284 +0,0 @@ -// -// Twitter+API.swift -// TwitterAPI -// -// Created by Cirno MainasuK on 2020-9-3. -// - -import Foundation -import NIOHTTP1 - -extension Twitter.API { - - public static let endpointURL = URL(string: "https://api.twitter.com/1.1/")! - public static let endpointV2URL = URL(string: "https://api.twitter.com/2/")! - public static let uploadEndpointURL = URL(string: "https://upload.twitter.com/1.1/")! - - public static let timeoutInterval: TimeInterval = 10 - public static let decoder: JSONDecoder = { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .twitterStrategy - return decoder - }() - public static let httpHeaderDateFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz" - return formatter - }() - -} - -extension Twitter.API { - - public enum Error { } - - public enum Account { } - public enum Application { } - public enum Block { } - public enum Favorites { } - public enum Friendships { } - public enum Geo { } - public enum List { } - public enum Lookup { } - public enum Media { } - public enum Mute { } - public enum OAuth { } - public enum SavedSearch { } - public enum Search { } - public enum Statuses { - public enum Timeline { } - } - public enum Trend { } - public enum Users { } - -} - -extension Twitter.API { - - enum Method: String { - case GET, POST, PATCH, PUT, DELETE - } - - static func request( - url: URL, - method: Method, - query: Query?, - authorization: Twitter.API.OAuth.Authorization - ) -> URLRequest { - var components = URLComponents(string: url.absoluteString)! - components.queryItems = query?.queryItems - if let encodedQueryItems = query?.encodedQueryItems { - let percentEncodedQueryItems = (components.percentEncodedQueryItems ?? []) + encodedQueryItems - components.percentEncodedQueryItems = percentEncodedQueryItems - } - - let requestURL = components.url! - var request = URLRequest( - url: requestURL, - cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, - timeoutInterval: Twitter.API.timeoutInterval - ) - request.httpMethod = method.rawValue - - let authorizationValue = authorization.authorizationHeader( - requestURL: requestURL, - requestFormQueryItems: query?.formQueryItems, - httpMethod: method.rawValue - ) - request.setValue( - authorizationValue, - forHTTPHeaderField: Twitter.API.OAuth.authorizationField - ) - if let body = query?.body { - request.httpBody = body - } - if let contentType = query?.contentType { - request.setValue(contentType, forHTTPHeaderField: "Content-Type") - } - - return request - } - - static func request( - url: URL, - method: Method, - query: Query?, - authorization: Twitter.API.V2.OAuth2.Authorization - ) -> URLRequest { - var components = URLComponents(string: url.absoluteString)! - components.queryItems = query?.queryItems - if let encodedQueryItems = query?.encodedQueryItems { - let percentEncodedQueryItems = (components.percentEncodedQueryItems ?? []) + encodedQueryItems - components.percentEncodedQueryItems = percentEncodedQueryItems - } - - let requestURL = components.url! - var request = URLRequest( - url: requestURL, - cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, - timeoutInterval: Twitter.API.timeoutInterval - ) - request.httpMethod = method.rawValue - - let authorizationValue = authorization.authorizationHeader - request.setValue( - authorizationValue, - forHTTPHeaderField: Twitter.API.OAuth.authorizationField - ) - if let body = query?.body { - request.httpBody = body - } - if let contentType = query?.contentType { - request.setValue(contentType, forHTTPHeaderField: "Content-Type") - } - - return request - } - -} - -extension Twitter.API { - // Error Response when request V1 endpoint - struct ErrorResponse: Codable { - let errors: [ErrorDescription] - - struct ErrorDescription: Codable { - public let code: Int - public let message: String - } - } - - // Alternative Error Response when request V1 endpoint - struct ErrorRequestResponse: Codable { - let request: String - let error: String - } - - public struct ErrorResponseV2: Codable { - public let errors: [ErrorDescription] - public let title: String? - public let detail: String? - public let type: String? - - public struct ErrorDescription: Codable { - public let parameter: String? - public let parameters: ErrorDescriptionParameters? - - public let value: String? - public let message: String? - - public let title: String? - public let detail: String? - public let type: String? - } - - public struct ErrorDescriptionParameters: Codable { - public let expansions: [String]? - public let mediaFields: [String]? - public let placeFields: [String]? - public let poolFields: [String]? - public let userFields: [String]? - public let tweetFields: [String]? - - public enum CodingKeys: String, CodingKey { - case expansions - case mediaFields = "media.fields" - case placeFields = "place.fields" - case poolFields = "pool.fields" - case userFields = "user.fields" - case tweetFields = "tweet.fields" - } - } - } -} - -extension Twitter.API { - static func decode(type: T.Type, from data: Data, response: URLResponse) throws -> T where T : Decodable { - // decode data then decode error if could - do { - return try Twitter.API.decoder.decode(type, from: data) - } catch let decodeError { - #if DEBUG - print(String(data: data, encoding: .utf8) ?? "") - debugPrint(decodeError) - #endif - - guard let httpURLResponse = response as? HTTPURLResponse else { - assertionFailure() - throw decodeError - } - let httpResponseStatus = HTTPResponseStatus(statusCode: httpURLResponse.statusCode) - - if let errorResponse = try? Twitter.API.decoder.decode(ErrorResponse.self, from: data), - let twitterAPIError = Error.TwitterAPIError(errorResponse: errorResponse) { - throw Error.ResponseError(httpResponseStatus: httpResponseStatus, twitterAPIError: twitterAPIError) - } - - if let errorRequestResponse = try? Twitter.API.decoder.decode(ErrorRequestResponse.self, from: data), - let twitterAPIError = Error.TwitterAPIError(errorRequestResponse: errorRequestResponse) { - throw Error.ResponseError(httpResponseStatus: httpResponseStatus, twitterAPIError: twitterAPIError) - } - - if let errorResponseV2 = try? Twitter.API.decoder.decode(ErrorResponseV2.self, from: data), - let twitterAPIError = Error.TwitterAPIError(errorResponseV2: errorResponseV2) { - throw Error.ResponseError(httpResponseStatus: httpResponseStatus, twitterAPIError: twitterAPIError) - } - - // Twitter not return error code described in the document. Convert manually - if httpURLResponse.statusCode == 429 { - throw Error.ResponseError(httpResponseStatus: httpResponseStatus, twitterAPIError: .rateLimitExceeded) - } - - throw Error.ResponseError(httpResponseStatus: httpResponseStatus, twitterAPIError: nil) - } - } - - @available(*, deprecated, message: "") - static func request(url: URL, httpMethod: String, authorization: Twitter.API.OAuth.Authorization, queryItems: [URLQueryItem]? = nil, encodedQueryItems: [URLQueryItem]? = nil, formQueryItems: [URLQueryItem]? = nil) -> URLRequest { - var components = URLComponents(string: url.absoluteString)! - components.queryItems = queryItems - if let encodedQueryItems = encodedQueryItems { - components.percentEncodedQueryItems = (components.percentEncodedQueryItems ?? []) + encodedQueryItems - } - let requestURL = components.url! - var request = URLRequest( - url: requestURL, - cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, - timeoutInterval: Twitter.API.timeoutInterval - ) - request.setValue( - authorization.authorizationHeader(requestURL: requestURL, requestFormQueryItems: formQueryItems, httpMethod: httpMethod), - forHTTPHeaderField: Twitter.API.OAuth.authorizationField - ) - request.httpMethod = httpMethod - return request - } - -} - -extension JSONDecoder.DateDecodingStrategy { - fileprivate static let twitterStrategy = custom { decoder throws -> Date in - let container = try decoder.singleValueContainer() - let string = try container.decode(String.self) - - let formatterV1 = DateFormatter() - formatterV1.locale = Locale(identifier: "en") - formatterV1.dateFormat = "EEE MMM dd HH:mm:ss ZZZZZ yyyy" - if let date = formatterV1.date(from: string) { - return date - } - - let formatterV2 = ISO8601DateFormatter() - formatterV2.formatOptions.insert(.withFractionalSeconds) - if let date = formatterV2.date(from: string) { - return date - } - - let formatterV3 = ISO8601DateFormatter() - if let date = formatterV3.date(from: string) { - return date - } - - throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid date: \(string)") - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+List+Member.swift b/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+List+Member.swift deleted file mode 100644 index be18c9e4..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+List+Member.swift +++ /dev/null @@ -1,105 +0,0 @@ -// -// Twitter+API+V2+List+Member.swift -// -// -// Created by MainasuK on 2022-3-24. -// - -import Foundation - -extension Twitter.API.V2.List { - public enum Member { } -} - -// add: https://developer.twitter.com/en/docs/twitter-api/lists/list-members/api-reference/post-lists-id-members -extension Twitter.API.V2.List.Member { - - private static func addMemberEndpointURL(listID: Twitter.Entity.V2.List.ID) -> URL { - return Twitter.API.endpointV2URL - .appendingPathComponent("lists") - .appendingPathComponent(listID) - .appendingPathComponent("members") - } - - public static func add( - session: URLSession, - listID: Twitter.Entity.V2.List.ID, - query: AddMemberQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: addMemberEndpointURL(listID: listID), - method: .POST, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: Twitter.API.V2.List.Member.AddMemberContent.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - public struct AddMemberQuery: JSONEncodeQuery { - public let userID: Twitter.Entity.V2.User.ID - - enum CodingKeys: String, CodingKey { - case userID = "user_id" - } - - public init(userID: Twitter.Entity.V2.User.ID) { - self.userID = userID - } - - var queryItems: [URLQueryItem]? { nil } - var encodedQueryItems: [URLQueryItem]? { nil } - var formQueryItems: [URLQueryItem]? { nil } - } - - public struct AddMemberContent: Codable { - public let data: ContentData - - public struct ContentData: Codable { - public let isMember: Bool - - enum CodingKeys: String, CodingKey { - case isMember = "is_member" - } - } - } - -} - -// remove: https://developer.twitter.com/en/docs/twitter-api/lists/list-members/api-reference/delete-lists-id-members-user_id -extension Twitter.API.V2.List.Member { - - private static func removeMemberEndpointURL( - listID: Twitter.Entity.V2.List.ID, - userID: Twitter.Entity.V2.User.ID - ) -> URL { - return Twitter.API.endpointV2URL - .appendingPathComponent("lists") - .appendingPathComponent(listID) - .appendingPathComponent("members") - .appendingPathComponent(userID) - } - - public static func remove( - session: URLSession, - listID: Twitter.Entity.V2.List.ID, - userID: Twitter.Entity.V2.User.ID, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: removeMemberEndpointURL(listID: listID, userID: userID), - method: .DELETE, - query: nil, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: Twitter.API.V2.List.Member.RemoveMemberContent.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - public typealias RemoveMemberContent = AddMemberContent - -} - diff --git a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+List.swift b/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+List.swift deleted file mode 100644 index 0aa8401b..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+List.swift +++ /dev/null @@ -1,318 +0,0 @@ -// -// Twitter+API+V2+List.swift -// -// -// Created by MainasuK on 2022-3-11. -// - -import Foundation - -extension Twitter.API.V2 { - public enum List { } -} - -// lookup: https://developer.twitter.com/en/docs/twitter-api/lists/list-lookup/api-reference/get-lists-id -extension Twitter.API.V2.List { - - private static func lookupEndpointURL(listID: Twitter.Entity.V2.List.ID) -> URL { - return Twitter.API.endpointV2URL - .appendingPathComponent("lists") - .appendingPathComponent(listID) - } - - public static func lookup( - session: URLSession, - listID: Twitter.Entity.V2.List.ID, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: lookupEndpointURL(listID: listID), - method: .GET, - query: LookupQuery(), - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: Twitter.API.V2.List.LookupContent.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - public struct LookupQuery: Query { - - var queryItems: [URLQueryItem]? { - let items: [URLQueryItem] = [ - [Twitter.Request.Expansions.ownerID].queryItem, - Twitter.Request.userFields.queryItem, - Twitter.Request.listFields.queryItem, - ] - return items - } - var encodedQueryItems: [URLQueryItem]? { nil } - var formQueryItems: [URLQueryItem]? { nil } - var contentType: String? { nil } - var body: Data? { nil } - } - - public struct LookupContent: Codable { - public let data: Twitter.Entity.V2.List - public let includes: Includes? - - public struct Includes: Codable { - public let users: [Twitter.Entity.V2.User] - } - } - -} - -// https://developer.twitter.com/en/docs/twitter-api/lists/list-follows/api-reference/get-lists-id-followers -extension Twitter.API.V2.List { - - private static func followerEndpointURL(listID: Twitter.Entity.V2.List.ID) -> URL { - return Twitter.API.endpointV2URL - .appendingPathComponent("lists") - .appendingPathComponent(listID) - .appendingPathComponent("followers") - } - - public static func follower( - session: URLSession, - listID: Twitter.Entity.V2.List.ID, - query: FollowerQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: followerEndpointURL(listID: listID), - method: .GET, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: Twitter.API.V2.List.FollowerContent.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - public struct FollowerQuery: Query { - public let maxResults: Int - public let nextToken: String? - - public init( - maxResults: Int = 20, - nextToken: String? - ) { - self.maxResults = min(100, max(10, maxResults)) - self.nextToken = nextToken - } - - var queryItems: [URLQueryItem]? { - var items: [URLQueryItem] = [ - Twitter.Request.userFields.queryItem, - URLQueryItem(name: "max_results", value: String(maxResults)), - ] - if let nextToken = nextToken { - let item = URLQueryItem(name: "pagination_token", value: nextToken) - items.append(item) - } - return items - } - var encodedQueryItems: [URLQueryItem]? { nil } - var formQueryItems: [URLQueryItem]? { nil } - var contentType: String? { nil } - var body: Data? { nil } - } - - public struct FollowerContent: Codable { - public let data: [Twitter.Entity.V2.User]? - public let meta: Meta - - public struct Meta: Codable { - public let resultCount: Int - public let previousToken: String? - public let nextToken: String? - - enum CodingKeys: String, CodingKey { - case resultCount = "result_count" - case previousToken = "previous_token" - case nextToken = "next_token" - } - } - } - -} - -// https://developer.twitter.com/en/docs/twitter-api/lists/list-members/api-reference/get-lists-id-members -extension Twitter.API.V2.List { - - private static func memberEndpointURL(listID: Twitter.Entity.V2.List.ID) -> URL { - return Twitter.API.endpointV2URL - .appendingPathComponent("lists") - .appendingPathComponent(listID) - .appendingPathComponent("members") - } - - public static func member( - session: URLSession, - listID: Twitter.Entity.V2.List.ID, - query: MemberQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: memberEndpointURL(listID: listID), - method: .GET, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: Twitter.API.V2.List.FollowerContent.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - public typealias MemberQuery = FollowerQuery - public typealias MemberContent = FollowerContent - -} - -// create: https://developer.twitter.com/en/docs/twitter-api/lists/manage-lists/api-reference/post-lists -extension Twitter.API.V2.List { - - private static func listsEndpointURL() -> URL { - return Twitter.API.endpointV2URL - .appendingPathComponent("lists") - } - - public static func create( - session: URLSession, - query: CreateQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: listsEndpointURL(), - method: .POST, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: Twitter.API.V2.List.CreateContent.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - public struct CreateQuery: JSONEncodeQuery { - public let name: String - public let description: String? - public let `private`: Bool? - - public init( - name: String, - description: String?, - private: Bool? - ) { - self.name = name - self.description = description - self.private = `private` - } - - var queryItems: [URLQueryItem]? { nil } - var encodedQueryItems: [URLQueryItem]? { nil } - var formQueryItems: [URLQueryItem]? { nil } - } - - public struct CreateContent: Codable { - public let data: ContentData - - public struct ContentData: Codable { - public let id: Twitter.Entity.V2.List.ID - public let name: String - } - } - -} - -// update: https://developer.twitter.com/en/docs/twitter-api/lists/manage-lists/api-reference/put-lists-id -extension Twitter.API.V2.List { - - private static func updateListEndpointURL(listID: Twitter.Entity.V2.List.ID) -> URL { - return Twitter.API.endpointV2URL - .appendingPathComponent("lists") - .appendingPathComponent(listID) - } - - public static func update( - session: URLSession, - listID: Twitter.Entity.V2.List.ID, - query: UpdateQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: updateListEndpointURL(listID: listID), - method: .PUT, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: Twitter.API.V2.List.UpdateContent.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - public struct UpdateQuery: JSONEncodeQuery { - public let name: String? - public let description: String? - public let `private`: Bool? - - public init( - name: String?, - description: String?, - private: Bool? - ) { - self.name = name - self.description = description - self.private = `private` - } - - var queryItems: [URLQueryItem]? { nil } - var encodedQueryItems: [URLQueryItem]? { nil } - var formQueryItems: [URLQueryItem]? { nil } - } - - public struct UpdateContent: Codable { - public let data: ContentData - - public struct ContentData: Codable { - public let updated: Bool - } - } - -} - - -// delete: https://developer.twitter.com/en/docs/twitter-api/lists/manage-lists/api-reference/delete-lists-id -extension Twitter.API.V2.List { - - private static func deleteListEndpointURL(listID: Twitter.Entity.V2.List.ID) -> URL { - return Twitter.API.endpointV2URL - .appendingPathComponent("lists") - .appendingPathComponent(listID) - } - - public static func delete( - session: URLSession, - listID: Twitter.Entity.V2.List.ID, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: deleteListEndpointURL(listID: listID), - method: .DELETE, - query: nil, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: Twitter.API.V2.List.DeleteContent.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - public struct DeleteContent: Codable { - public let data: ContentData - - public struct ContentData: Codable { - public let deleted: Bool - } - } - -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+Lookup.swift b/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+Lookup.swift deleted file mode 100644 index 725c4889..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+Lookup.swift +++ /dev/null @@ -1,88 +0,0 @@ -// -// Twitter+API+V2+Lookup.swift -// TwitterAPI -// -// Created by Cirno MainasuK on 2020-9-15. -// - -import Foundation -import Combine - -extension Twitter.API.V2 { - public enum Lookup { } -} - -// https://developer.twitter.com/en/docs/twitter-api/tweets/lookup/api-reference/get-tweets -extension Twitter.API.V2.Lookup { - - static let tweetsEndpointURL = Twitter.API.endpointV2URL.appendingPathComponent("tweets") - - public static func statuses( - session: URLSession, - query: StatusLookupQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: tweetsEndpointURL, - method: .GET, - query: query, - authorization: authorization - ) - - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: Twitter.API.V2.Lookup.Content.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - static var expansions: [Twitter.Request.Expansions] { - return [ - .attachmentsPollIDs, - .attachmentsMediaKeys, - .authorID, - .entitiesMentionsUsername, - .geoPlaceID, - .inReplyToUserID, - .referencedTweetsID, - .referencedTweetsIDAuthorID - ] - } - - public struct StatusLookupQuery: Query { - public let statusIDs: [Twitter.Entity.Tweet.ID] - - public init(statusIDs: [Twitter.Entity.Tweet.ID]) { - self.statusIDs = statusIDs - } - - var queryItems: [URLQueryItem]? { - let ids = statusIDs.joined(separator: ",") - return [ - Twitter.API.V2.Lookup.expansions.queryItem, - Twitter.Request.tweetsFields.queryItem, - Twitter.Request.userFields.queryItem, - Twitter.Request.mediaFields.queryItem, - Twitter.Request.placeFields.queryItem, - Twitter.Request.pollFields.queryItem, - URLQueryItem(name: "ids", value: ids), - ] - } - var encodedQueryItems: [URLQueryItem]? { nil } - var formQueryItems: [URLQueryItem]? { nil } - var contentType: String? { nil } - var body: Data? { nil } - } - - public struct Content: Codable { - public let data: [Twitter.Entity.V2.Tweet]? - public let includes: Include? - - public struct Include: Codable { - public let users: [Twitter.Entity.V2.User]? - public let tweets: [Twitter.Entity.V2.Tweet]? - public let media: [Twitter.Entity.V2.Media]? - public let places: [Twitter.Entity.V2.Place]? - public let polls: [Twitter.Entity.V2.Tweet.Poll]? - } - } - -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+OAuth2+AccessToken.swift b/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+OAuth2+AccessToken.swift deleted file mode 100644 index 151eefea..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+OAuth2+AccessToken.swift +++ /dev/null @@ -1,101 +0,0 @@ -// -// Twitter+API+V2+OAuth2+AccessToken.swift -// -// -// Created by MainasuK on 2022-4-24. -// - -import Foundation - -extension Twitter.API.V2.OAuth2 { - public enum AccessToken { } -} - -extension Twitter.API.V2.OAuth2.AccessToken { - - static let accessTokenURL = URL(string: "https://api.twitter.com/2/oauth2/token")! - - public static func accessToken( - session: URLSession, - query: AccessTokenQuery - ) async throws -> AccessTokenResponse { - var request = URLRequest( - url: accessTokenURL, - cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, - timeoutInterval: Twitter.API.timeoutInterval - ) - request.httpMethod = "POST" - if let contentType = query.contentType { - request.setValue(contentType, forHTTPHeaderField: "Content-Type") - } - if let body = query.body { - request.httpBody = body - } - - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: AccessTokenResponse.self, from: data, response: response) - return value - } - - public struct AccessTokenQuery: Query { - public let code: String - public let grantType: String - public let clientID: String - public let redirectURI: String - public let codeVerifier: String - - enum CodingKeys: String, CodingKey { - case code - case grantType = "grant_type" - case clientID = "client_id" - case redirectURI = "redirect_uri" - case codeVerifier = "code_verifier" - } - - public init( - code: String, - grantType: String = "authorization_code", - clientID: String, - redirectURI: String, - codeVerifier: String - ) { - self.code = code - self.grantType = grantType - self.clientID = clientID - self.redirectURI = redirectURI - self.codeVerifier = codeVerifier - } - - var queryItems: [URLQueryItem]? { nil } - var encodedQueryItems: [URLQueryItem]? { nil } - var formQueryItems: [URLQueryItem]? { nil } - var contentType: String? { "application/x-www-form-urlencoded" } - var body: Data? { - let content = [ - CodingKeys.code.rawValue: code, - CodingKeys.grantType.rawValue: grantType, - CodingKeys.clientID.rawValue: clientID, - CodingKeys.redirectURI.rawValue: redirectURI, - CodingKeys.codeVerifier.rawValue: codeVerifier, - ].urlEncodedQuery - return content.data(using: .utf8) - } - } - - public struct AccessTokenResponse: Codable { - public let tokenType: String - public let expiresIn: Int - public let scope: String - public let accessToken: String - public let refreshToken: String - - enum CodingKeys: String, CodingKey { - case tokenType = "token_type" - case expiresIn = "expires_in" - case scope - case accessToken = "access_token" - case refreshToken = "refresh_token" - } - } - -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+OAuth2+Authorize.swift b/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+OAuth2+Authorize.swift deleted file mode 100644 index 51cc9d6c..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+OAuth2+Authorize.swift +++ /dev/null @@ -1,257 +0,0 @@ -// -// Twitter+API+V2+OAuth2+Authorize.swift -// -// -// Created by MainasuK on 2022-4-24. -// - -import os.log -import Foundation -import CryptoKit - -extension Twitter.API.V2.OAuth2 { - public enum Authorize { - public enum Standard { } - public enum Relay { } - } -} - -extension Twitter.API.V2.OAuth2.Authorize.Standard { - - public struct OAuthCallback: Codable { - public let state: String - public let code: String - - enum CodingKeys: String, CodingKey, CaseIterable { - case state - case code - } - - public init?(callbackURL url: URL) { - guard let urlComponents = URLComponents(string: url.absoluteString) else { return nil } - guard let queryItems = urlComponents.queryItems, - let state = queryItems.first(where: { $0.name == CodingKeys.state.rawValue })?.value, - let code = queryItems.first(where: { $0.name == CodingKeys.code.rawValue })?.value else - { - return nil - } - self.state = state - self.code = code - } - } - -} - -extension Twitter.API.V2.OAuth2.Authorize.Relay { - - static let logger = Logger(subsystem: "Twitter.API.V2.OAuth2.Authorize.Relay", category: "API") - - static let callbackURL = URL(string: "twidere://authentication/oauth2/callback")! - - public static func authorize( - session: URLSession, - query: Query - ) async throws -> Response { - - let clientEphemeralPrivateKey = Curve25519.KeyAgreement.PrivateKey() - let clientEphemeralPublicKey = clientEphemeralPrivateKey.publicKey - do { - let sharedSecret = try clientEphemeralPrivateKey.sharedSecretFromKeyAgreement(with: query.hostPublicKey) - let salt = clientEphemeralPublicKey.rawRepresentation + sharedSecret.withUnsafeBytes { Data($0) } - let wrapKey = sharedSecret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: salt, sharedInfo: Data("oauth2".utf8), outputByteCount: 32) - let box = Box( - clientID: query.clientID, - consumerKey: query.consumerKey, - consumerKeySecret: query.consumerKeySecret - ) - let boxData = try JSONEncoder().encode(box) - let sealedBox = try ChaChaPoly.seal(boxData, using: wrapKey) - let payload = Payload( - exchangePublicKey: clientEphemeralPublicKey.rawRepresentation.base64EncodedString(), - box: sealedBox.combined.base64EncodedString() - ) - var request = URLRequest( - url: query.endpoint.appendingPathComponent("/oauth2"), - cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, - timeoutInterval: Twitter.API.timeoutInterval - ) - request.httpMethod = "POST" - request.addValue("application/json; charset=UTF-8", forHTTPHeaderField: "Content-Type") - request.httpBody = try JSONEncoder().encode(payload) - - let (data, _) = try await session.data(for: request, delegate: nil) - let content = try JSONDecoder().decode(Response.Content.self, from: data) - os_log("%{public}s[%{public}ld], %{public}s: request token response: %s", ((#file as NSString).lastPathComponent), #line, #function, String(describing: content)) - let response = Response( - content: content, - append: .init(clientExchangePrivateKey: clientEphemeralPrivateKey) - ) - return response - } catch { - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): error: \(error.localizedDescription)") - throw error - } - } // end func - - public struct Query { - public let clientID: String - public let consumerKey: String - public let consumerKeySecret: String? - public let endpoint: URL - public let hostPublicKey: Curve25519.KeyAgreement.PublicKey - - public init( - clientID: String, - consumerKey: String, - consumerKeySecret: String?, - endpoint: URL, - hostPublicKey: Curve25519.KeyAgreement.PublicKey - ) { - self.clientID = clientID - self.consumerKey = consumerKey - self.consumerKeySecret = consumerKeySecret - self.endpoint = endpoint - self.hostPublicKey = hostPublicKey - } - } - - public struct Response { - public let content: Content - public let append: Append - - public struct Content: Codable { - public let challenge: String - public let state: String - - public init( - challenge: String, - state: String - ) { - self.challenge = challenge - self.state = state - } - } - - public struct Append { - public let clientExchangePrivateKey: Curve25519.KeyAgreement.PrivateKey - } - } - - - public struct Payload: Codable { - /// client ephemeral public key` - public let exchangePublicKey: String - - /// sealed Box - public let box: String - - enum CodingKeys: String, CodingKey, CaseIterable { - case exchangePublicKey = "exchange_public_key" - case box - } - } - - public struct Box: Codable { - public let clientID: String - public let consumerKey: String - public let consumerKeySecret: String? - - enum CodingKeys: String, CodingKey, CaseIterable { - case clientID = "client_id" - case consumerKey = "consumer_key" - case consumerKeySecret = "consumer_key_secret" - } - } - - public struct OAuthCallback: Codable { - let exchangePublicKey: String - let authenticationBox: String - - enum CodingKeys: String, CodingKey, CaseIterable { - case exchangePublicKey = "exchange_public_key" - case authenticationBox = "authentication_box" - } - - public init?(callbackURL url: URL) { - guard let urlComponents = URLComponents(string: url.absoluteString) else { return nil } - guard let queryItems = urlComponents.queryItems, - let exchangePublicKey = queryItems.first(where: { $0.name == CodingKeys.exchangePublicKey.rawValue })?.value, - let authenticationBox = queryItems.first(where: { $0.name == CodingKeys.authenticationBox.rawValue })?.value else - { - return nil - } - self.exchangePublicKey = exchangePublicKey - self.authenticationBox = authenticationBox - } - - public func authentication(privateKey: Curve25519.KeyAgreement.PrivateKey) throws -> Authentication { - do { - guard let exchangePublicKeyData = Data(base64Encoded: exchangePublicKey), - let sealedBoxData = Data(base64Encoded: authenticationBox) else { - throw Twitter.API.Error.InternalError(message: "invalid callback") - } - let exchangePublicKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: exchangePublicKeyData) - let sharedSecret = try privateKey.sharedSecretFromKeyAgreement(with: exchangePublicKey) - let salt = exchangePublicKey.rawRepresentation + sharedSecret.withUnsafeBytes { Data($0) } - let wrapKey = sharedSecret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: salt, sharedInfo: Data("authentication exchange".utf8), outputByteCount: 32) - let sealedBox = try ChaChaPoly.SealedBox(combined: sealedBoxData) - - let authenticationData = try ChaChaPoly.open(sealedBox, using: wrapKey) - let authentication = try JSONDecoder().decode(Authentication.self, from: authenticationData) - return authentication - - } catch { - if let error = error as? Twitter.API.Error.ResponseError { - throw error - } else { - throw Twitter.API.Error.InternalError(message: error.localizedDescription) - } - } - } - } - - public struct Authentication: Codable { - // oauth1.0a - public let oauthConsumerKey: String - public let oauthConsumerSecret: String - public let oauthAccessToken: String - public let oauthAccessTokenSecret: String - public let userID: String - public let screenName: String - // oauth2.0 - public let oauth2AccessToken: String - public let oauth2RefreshToken: String - - enum CodingKeys: String, CodingKey { - case oauthConsumerKey = "oauth_consumer_key" - case oauthConsumerSecret = "oauth_consumer_secret" - case oauthAccessToken = "oauth_access_token" - case oauthAccessTokenSecret = "oauth_access_token_secret" - case userID = "user_id" - case screenName = "screen_name" - case oauth2AccessToken = "oauth2_access_token" - case oauth2RefreshToken = "oauth2_refresh_token" - } - - public init( - oauthConsumerKey: String, - oauthConsumerSecret: String, - oauthAccessToken: String, - oauthAccessTokenSecret: String, - userID: String, - screenName: String, - oauth2AccessToken: String, - oauth2RefreshToken: String - ) { - self.oauthConsumerKey = oauthConsumerKey - self.oauthConsumerSecret = oauthConsumerSecret - self.oauthAccessToken = oauthAccessToken - self.oauthAccessTokenSecret = oauthAccessTokenSecret - self.userID = userID - self.screenName = screenName - self.oauth2AccessToken = oauth2AccessToken - self.oauth2RefreshToken = oauth2RefreshToken - } - } - -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+OAuth2.swift b/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+OAuth2.swift deleted file mode 100644 index e4c62d62..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+OAuth2.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// Twitter+API+V2+OAuth2.swift -// -// -// Created by MainasuK on 2022-4-21. -// - -import os.log -import Foundation -import CryptoKit - -extension Twitter.API.V2 { - public enum OAuth2 { } -} - -extension Twitter.API.V2.OAuth2 { - - static let logger = Logger(subsystem: "Twitter.API.V2.OAuth2", category: "API") - static let authorizeEndpointURL = URL(string: "https://twitter.com/i/oauth2/authorize")! -} - -extension Twitter.API.V2.OAuth2 { - - public static func authorizeURL( - endpoint: URL, - clientID: String, - challenge: String, - state: String - ) -> URL { - let redirectURI = endpoint - .appendingPathComponent("oauth2") - .appendingPathComponent("callback") - var components = URLComponents(string: authorizeEndpointURL.absoluteString)! - components.percentEncodedQueryItems = [ - URLQueryItem(name: "response_type", value: "code"), - URLQueryItem(name: "client_id", value: clientID.urlEncoded), - URLQueryItem(name: "scope", value: "tweet.read users.read follows.read follows.write offline.access bookmark.read".urlEncoded), - URLQueryItem(name: "state", value: state), - URLQueryItem(name: "code_challenge", value: challenge), - URLQueryItem(name: "code_challenge_method", value: "S256"), - URLQueryItem(name: "redirect_uri", value: redirectURI.absoluteString.urlEncoded), - ] - let authorizeURL = components.url! - return authorizeURL - } - -} - -extension Twitter.API.V2.OAuth2 { - - public struct Authorization: Hashable { - public let accessToken: String - public let refreshToken: String - - public init( - accessToken: String, - refreshToken: String - ) { - self.accessToken = accessToken - self.refreshToken = refreshToken - } - - var authorizationHeader: String { - return "Bearer \(accessToken)" - } - } - -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+Search.swift b/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+Search.swift deleted file mode 100644 index 675838ef..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+Search.swift +++ /dev/null @@ -1,130 +0,0 @@ -// -// Twitter+API+V2+Search.swift -// -// -// Created by Cirno MainasuK on 2020-10-16. -// - -import os.log -import Foundation -import Combine - -extension Twitter.API.V2 { - public enum Search { } -} - -/// https://developer.twitter.com/en/docs/twitter-api/tweets/search/api-reference -extension Twitter.API.V2.Search { - - static let tweetsSearchRecentEndpointURL = Twitter.API.endpointV2URL.appendingPathComponent("tweets/search/recent") - - public static func recentTweet( - session: URLSession, - query: Twitter.API.V2.Search.RecentTweetQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: tweetsSearchRecentEndpointURL, - method: .GET, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: Twitter.API.V2.Search.Content.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - static var expansions: [Twitter.Request.Expansions] { - return [ - .attachmentsPollIDs, - .attachmentsMediaKeys, - .authorID, - .entitiesMentionsUsername, - .geoPlaceID, - .inReplyToUserID, - .referencedTweetsID, - .referencedTweetsIDAuthorID - ] - } - - public struct RecentTweetQuery: Query { - public let query: String - public let maxResults: Int - public let sinceID: Twitter.Entity.V2.Tweet.ID? - public let startTime: Date? - public let nextToken: String? - - public init( - query: String, - maxResults: Int, - sinceID: Twitter.Entity.V2.Tweet.ID?, - startTime: Date?, - nextToken: String? - ) { - self.query = query - self.maxResults = min(100, max(10, maxResults)) - self.sinceID = sinceID - self.startTime = startTime - self.nextToken = nextToken - } - - var queryItems: [URLQueryItem]? { - var items: [URLQueryItem] = [ - Twitter.API.V2.Search.expansions.queryItem, - Twitter.Request.tweetsFields.queryItem, - Twitter.Request.userFields.queryItem, - Twitter.Request.mediaFields.queryItem, - Twitter.Request.placeFields.queryItem, - Twitter.Request.pollFields.queryItem, - URLQueryItem(name: "max_results", value: String(maxResults)), - ] - sinceID.flatMap { items.append(URLQueryItem(name: "since_id", value: $0)) } - nextToken.flatMap { items.append(URLQueryItem(name: "next_token", value: $0)) } - return items - } - var encodedQueryItems: [URLQueryItem]? { - var items: [URLQueryItem] = [ - URLQueryItem(name: "query", value: query.urlEncoded) - ] - if let startTime = startTime { - let formatter = ISO8601DateFormatter() - let time = formatter.string(from: startTime) - let item = URLQueryItem(name: "start_time", value: time.urlEncoded) - items.append(item) - } - return items - } - var formQueryItems: [URLQueryItem]? { nil } - var contentType: String? { nil } - var body: Data? { nil } - } - - public struct Content: Codable { - public let data: [Twitter.Entity.V2.Tweet]? - public let includes: Include? - public let meta: Meta - - public struct Include: Codable { - public let users: [Twitter.Entity.V2.User]? - public let tweets: [Twitter.Entity.V2.Tweet]? - public let media: [Twitter.Entity.V2.Media]? - public let places: [Twitter.Entity.V2.Place]? - public let polls: [Twitter.Entity.V2.Tweet.Poll]? - } - - public struct Meta: Codable { - public let newestID: String? - public let oldestID: String? - public let resultCount: Int - public let nextToken: String? - - public enum CodingKeys: String, CodingKey { - case newestID = "newest_id" - case oldestID = "oldest_id" - case resultCount = "result_count" - case nextToken = "next_token" - } - } - } - -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+Status+Delete.swift b/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+Status+Delete.swift deleted file mode 100644 index 8f1e52a6..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+Status+Delete.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// Twitter+API+V2+Status+Delete.swift -// -// -// Created by MainasuK on 2021-12-16. -// - -import Foundation - -extension Twitter.API.V2.Status { - public enum Delete { } -} - -// doc: https://developer.twitter.com/en/docs/twitter-api/tweets/manage-tweets/api-reference/delete-tweets-id -extension Twitter.API.V2.Status.Delete { - - private static func deleteEndpointURL(statusID: Twitter.Entity.V2.Tweet.ID) -> URL { - return Twitter.API.endpointV2URL - .appendingPathComponent("tweets") - .appendingPathComponent(statusID) - } - - public static func delete( - session: URLSession, - statusID: Twitter.Entity.V2.Tweet.ID, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: deleteEndpointURL(statusID: statusID), - method: .DELETE, - query: nil, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: DeleteContent.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - public struct DeleteContent: Codable { - public let data: ContentData - - public struct ContentData: Codable { - public let deleted: Bool - } - } - -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+Status+List.swift b/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+Status+List.swift deleted file mode 100644 index cd4db75b..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+Status+List.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// Twitter+API+V2+Status+List.swift -// -// -// Created by MainasuK on 2022-3-2. -// - -import Foundation - -extension Twitter.API.V2.Status { - public enum List { } -} - -// List Tweets Lookup -// https://developer.twitter.com/en/docs/twitter-api/lists/list-tweets/api-reference/get-lists-id-tweets -extension Twitter.API.V2.Status.List { - - static func tweetsEndpointURL(listID: Twitter.Entity.V2.List.ID) -> URL { - return Twitter.API.endpointV2URL - .appendingPathComponent("lists") - .appendingPathComponent(listID) - .appendingPathComponent("tweets") - } - - public static func statuses( - session: URLSession, - listID: Twitter.Entity.V2.List.ID, - query: StatusesQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: tweetsEndpointURL(listID: listID), - method: .GET, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: StatusesContent.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - public struct StatusesQuery: Query { - public let maxResults: Int - public let nextToken: String? - - public init( - maxResults: Int = 20, - nextToken: String? - ) { - self.maxResults = min(100, max(10, maxResults)) - self.nextToken = nextToken - } - - var queryItems: [URLQueryItem]? { - var items: [URLQueryItem] = [ - [Twitter.Request.Expansions.authorID].queryItem, - Twitter.Request.userFields.queryItem, - Twitter.Request.tweetsFields.queryItem, - URLQueryItem(name: "max_results", value: String(maxResults)), - ] - if let nextToken = nextToken { - let item = URLQueryItem(name: "pagination_token", value: nextToken) - items.append(item) - } - return items - } - var encodedQueryItems: [URLQueryItem]? { nil } - var formQueryItems: [URLQueryItem]? { nil } - var contentType: String? { nil } - var body: Data? { nil } - } - - public struct StatusesContent: Codable { - public let data: [Twitter.Entity.V2.Tweet]? - public let includes: Includes? - public let meta: Meta - - public struct Includes: Codable { - public let users: [Twitter.Entity.V2.User] - } - - public struct Meta: Codable { - public let resultCount: Int - public let previousToken: String? - public let nextToken: String? - - enum CodingKeys: String, CodingKey { - case resultCount = "result_count" - case previousToken = "previous_token" - case nextToken = "next_token" - } - } - } - -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+Status.swift b/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+Status.swift deleted file mode 100644 index 3b9302ed..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+Status.swift +++ /dev/null @@ -1,144 +0,0 @@ -// -// Twitter+API+V2+Status.swift -// -// -// Created by MainasuK on 2022-4-21. -// - -import Foundation - -extension Twitter.API.V2 { - public enum Status { } -} - -// doc: https://developer.twitter.com/en/docs/twitter-api/tweets/manage-tweets/api-reference/post-tweets -extension Twitter.API.V2.Status { - - private static var tweetEndpointURL: URL { - return Twitter.API.endpointV2URL - .appendingPathComponent("tweets") - } - - public static func publish( - session: URLSession, - query: PublishQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: tweetEndpointURL, - method: .POST, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: PublishContent.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - public struct PublishQuery: JSONEncodeQuery { - - public let geo: Twitter.Entity.V2.Tweet.Geo? - public let media: Media? - public let poll: Poll? - public let reply: Reply? - public let forSuperFollowersOnly: Bool? - public let replySettings: Twitter.Entity.V2.Tweet.ReplySettings? - public let text: String? - - enum CodingKeys: String, CodingKey { - case geo - case media - case poll - case reply - case forSuperFollowersOnly = "for_super_followers_only" - case replySettings = "reply_settings" - case text - } - - public init( - geo: Twitter.Entity.V2.Tweet.Geo?, - media: Media?, - poll: Poll?, - reply: Reply?, - forSuperFollowersOnly: Bool?, - replySettings: Twitter.Entity.V2.Tweet.ReplySettings?, - text: String? - ) { - self.geo = geo - self.media = media - self.poll = poll - self.reply = reply - self.forSuperFollowersOnly = forSuperFollowersOnly - self.text = text - self.replySettings = { - switch replySettings { - case .everyone: - return nil - default: - return replySettings - } - }() - } - - var queryItems: [URLQueryItem]? { nil } - var encodedQueryItems: [URLQueryItem]? { nil } - var formQueryItems: [URLQueryItem]? { nil } - } - - public struct PublishContent: Codable { - public let data: ContentData - - public struct ContentData: Codable { - public let id: String - public let text: String - } - } - -} - -extension Twitter.API.V2.Status { - public struct Media: Codable { - public let mediaIDs: [Twitter.Entity.V2.Media.ID]? - - enum CodingKeys: String, CodingKey { - case mediaIDs = "media_ids" - } - - public init(mediaIDs: [Twitter.Entity.V2.Media.ID]?) { - self.mediaIDs = mediaIDs - } - } - - public struct Poll: Codable { - public let options: [String] - public let durationMinutes: Int - - enum CodingKeys: String, CodingKey { - case options - case durationMinutes = "duration_minutes" - } - - public init(options: [String], durationMinutes: Int) { - self.options = options - self.durationMinutes = durationMinutes - } - } - - public struct Reply: Codable { - public let excludeReplyUserIDs: [Twitter.Entity.V2.User.ID]? - public let inReplyToTweetID: Twitter.Entity.V2.Tweet.ID? - - enum CodingKeys: String, CodingKey { - case excludeReplyUserIDs = "exclude_reply_user_ids" - case inReplyToTweetID = "in_reply_to_tweet_id" - } - - public init( - excludeReplyUserIDs: [Twitter.Entity.V2.User.ID]?, - inReplyToTweetID: Twitter.Entity.V2.Tweet.ID? - ) { - self.excludeReplyUserIDs = excludeReplyUserIDs - self.inReplyToTweetID = inReplyToTweetID - } - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+User+Block.swift b/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+User+Block.swift deleted file mode 100644 index 4c9b97b3..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+User+Block.swift +++ /dev/null @@ -1,101 +0,0 @@ -// -// Twitter+API+V2+User+Block.swift -// -// -// Created by Cirno MainasuK on 2021-10-21. -// - -import Foundation - -extension Twitter.API.V2.User { - public enum Block { } -} - -// doc: https://developer.twitter.com/en/docs/twitter-api/users/blocks/introduction -extension Twitter.API.V2.User.Block { - - // https://developer.twitter.com/en/docs/twitter-api/users/blocks/api-reference/post-users-user_id-blocking - static func blockEndpointURL(sourceUserID: Twitter.Entity.User.ID) -> URL { - return Twitter.API.endpointV2URL - .appendingPathComponent("users") - .appendingPathComponent(sourceUserID) - .appendingPathComponent("blocking") - } - - public static func block( - session: URLSession, - sourceUserID: Twitter.Entity.User.ID, - query: BlockQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: blockEndpointURL(sourceUserID: sourceUserID), - method: .POST, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: BlockContent.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - public struct BlockQuery: JSONEncodeQuery { - - public let targetUserID: Twitter.Entity.User.ID - - public init(targetUserID: Twitter.Entity.User.ID) { - self.targetUserID = targetUserID - } - - enum CodingKeys: String, CodingKey { - case targetUserID = "target_user_id" - } - - var queryItems: [URLQueryItem]? { nil } - var encodedQueryItems: [URLQueryItem]? { nil } - var formQueryItems: [URLQueryItem]? { nil } - } - - public struct BlockContent: Codable { - public let data: ContentData - - public struct ContentData: Codable { - public let blocking: Bool - } - } - -} - -extension Twitter.API.V2.User.Block { - - // https://developer.twitter.com/en/docs/twitter-api/users/blocks/api-reference/delete-users-user_id-blocking - static func unblockEndpointURL( - sourceUserID: Twitter.Entity.User.ID, - targetUserID: Twitter.Entity.User.ID - ) -> URL { - return Twitter.API.endpointV2URL - .appendingPathComponent("users") - .appendingPathComponent(sourceUserID) - .appendingPathComponent("blocking") - .appendingPathComponent(targetUserID) - } - - public static func unblock( - session: URLSession, - sourceUserID: Twitter.Entity.User.ID, - targetUserID: Twitter.Entity.User.ID, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let url = unblockEndpointURL(sourceUserID: sourceUserID, targetUserID: targetUserID) - let request = Twitter.API.request( - url: url, - method: .DELETE, - query: nil, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: BlockContent.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+User+Follow.swift b/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+User+Follow.swift deleted file mode 100644 index 0d8c1680..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+User+Follow.swift +++ /dev/null @@ -1,218 +0,0 @@ -// -// Twitter+API+V2+User+Follow.swift -// -// -// Created by Cirno MainasuK on 2021-10-19. -// - -import Foundation - -extension Twitter.API.V2.User { - public enum Follow { } -} - -// Request follow user -// https://developer.twitter.com/en/docs/twitter-api/users/follows/introduction -extension Twitter.API.V2.User.Follow { - - static func followEndpointURL( - sourceUserID: Twitter.Entity.V2.User.ID - ) -> URL { - return Twitter.API.endpointV2URL - .appendingPathComponent("users") - .appendingPathComponent(sourceUserID) - .appendingPathComponent("following") - } - - public static func follow( - session: URLSession, - sourceUserID: Twitter.Entity.V2.User.ID, - targetUserID: Twitter.Entity.V2.User.ID, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let query = FollowQuery(targetUserID: targetUserID) - let request = Twitter.API.request( - url: followEndpointURL(sourceUserID: sourceUserID), - method: .POST, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: FollowContent.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - public struct FollowQuery: JSONEncodeQuery { - public let targetUserID: Twitter.Entity.V2.User.ID - - enum CodingKeys: String, CodingKey { - case targetUserID = "target_user_id" - } - - public init(targetUserID: Twitter.Entity.V2.User.ID) { - self.targetUserID = targetUserID - } - - var queryItems: [URLQueryItem]? { nil } - var encodedQueryItems: [URLQueryItem]? { nil } - var formQueryItems: [URLQueryItem]? { nil } - } - - public struct FollowContent: Codable { - public let data: ContentData - - public struct ContentData: Codable { - public let following: Bool - public let pendingFollow: Bool? - - enum CodingKeys: String, CodingKey { - case following - case pendingFollow = "pending_follow" - } - - } - } - -} - -// Cancel follow user -extension Twitter.API.V2.User.Follow { - - static func undoFollowEndpointURL( - sourceUserID: Twitter.Entity.V2.User.ID, - targetUserID: Twitter.Entity.V2.User.ID - ) -> URL { - return Twitter.API.endpointV2URL - .appendingPathComponent("users") - .appendingPathComponent(sourceUserID) - .appendingPathComponent("following") - .appendingPathComponent(targetUserID) - } - - public static func undoFollow( - session: URLSession, - sourceUserID: Twitter.Entity.V2.User.ID, - targetUserID: Twitter.Entity.V2.User.ID, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let url = undoFollowEndpointURL(sourceUserID: sourceUserID, targetUserID: targetUserID) - let request = Twitter.API.request( - url: url, - method: .DELETE, - query: nil, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: FollowContent.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - -} - -extension Twitter.API.V2.User.Follow { - public struct FriendshipListQuery: Query { - public let userID: Twitter.Entity.V2.User.ID - public let maxResults: Int - public let paginationToken: String? - - public init(userID: Twitter.Entity.V2.User.ID, maxResults: Int, paginationToken: String?) { - self.userID = userID - self.maxResults = min(1000, max(10, maxResults)) - self.paginationToken = paginationToken - } - - var queryItems: [URLQueryItem]? { - var items: [URLQueryItem] = [] - items.append(Twitter.Request.tweetsFields.queryItem) - items.append(Twitter.Request.userFields.queryItem) - items.append(URLQueryItem(name: "max_results", value: String(maxResults))) - paginationToken.flatMap { - items.append(URLQueryItem(name: "pagination_token", value: $0)) - } - guard !items.isEmpty else { return nil } - return items - } - var encodedQueryItems: [URLQueryItem]? { nil } - var formQueryItems: [URLQueryItem]? { nil } - var contentType: String? { nil } - var body: Data? { nil } - } - - public struct FriendshipListContent: Codable { - public let data: [Twitter.Entity.V2.User]? - public let includes: Include? - public let errors: [Twitter.Response.V2.ContentError]? - public let meta: Meta - - public struct Include: Codable { - public let tweets: [Twitter.Entity.V2.Tweet]? - } - - public struct Meta: Codable { - public let resultCount: Int - public let nextToken: String? - - public enum CodingKeys: String, CodingKey { - case resultCount = "result_count" - case nextToken = "next_token" - } - } - } -} - -// Returns a list of users the specified userID following -// https://developer.twitter.com/en/docs/twitter-api/users/follows/api-reference/get-users-id-following -extension Twitter.API.V2.User.Follow { - - static func followingEndpointURL(userID: Twitter.Entity.V2.User.ID) -> URL { - return Twitter.API.endpointV2URL - .appendingPathComponent("users") - .appendingPathComponent(userID) - .appendingPathComponent("following") - } - - public static func followingList( - session: URLSession, - query: Twitter.API.V2.User.Follow.FriendshipListQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: followingEndpointURL(userID: query.userID), - method: .GET, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: Twitter.API.V2.User.Follow.FriendshipListContent.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - -} - -// Returns a list of users who are followers of the specified user ID. -// https://developer.twitter.com/en/docs/twitter-api/users/follows/api-reference/get-users-id-followers -extension Twitter.API.V2.User.Follow { - - static func followerListEndpointURL(userID: Twitter.Entity.V2.User.ID) -> URL { - return Twitter.API.endpointV2URL - .appendingPathComponent("users") - .appendingPathComponent(userID) - .appendingPathComponent("followers") - } - - public static func followers( - session: URLSession, - query: Twitter.API.V2.User.Follow.FriendshipListQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: followerListEndpointURL(userID: query.userID), - method: .GET, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: Twitter.API.V2.User.Follow.FriendshipListContent.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+User+Like.swift b/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+User+Like.swift deleted file mode 100644 index 23c3ce2a..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+User+Like.swift +++ /dev/null @@ -1,99 +0,0 @@ -// -// Twitter+API+V2+User+Like.swift -// Twitter+API+V2+User+Like -// -// Created by Cirno MainasuK on 2021-9-8. -// - -import Foundation - -extension Twitter.API.V2.User { - public enum Like { } -} - -// https://developer.twitter.com/en/docs/twitter-api/tweets/likes/api-reference/post-users-id-likes -extension Twitter.API.V2.User.Like { - - static func likeEndpointURL( - userID: Twitter.Entity.V2.User.ID - ) -> URL { - return Twitter.API.endpointV2URL - .appendingPathComponent("users") - .appendingPathComponent(userID) - .appendingPathComponent("likes") - } - - public static func like( - session: URLSession, - query: LikeQuery, - userID: Twitter.Entity.V2.User.ID, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: likeEndpointURL(userID: userID), - method: .POST, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: LikeContent.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - public struct LikeQuery: JSONEncodeQuery { - public let tweetID: Twitter.Entity.V2.Tweet.ID - - enum CodingKeys: String, CodingKey { - case tweetID = "tweet_id" - } - - public init(tweetID: Twitter.Entity.V2.Tweet.ID) { - self.tweetID = tweetID - } - - var queryItems: [URLQueryItem]? { nil } - var encodedQueryItems: [URLQueryItem]? { nil } - var formQueryItems: [URLQueryItem]? { nil } - } - - public struct LikeContent: Codable { - public let data: ContentData - - public struct ContentData: Codable { - public let liked: Bool - } - } - -} - -extension Twitter.API.V2.User.Like { - - static func undoLikeEndpointURL( - userID: Twitter.Entity.V2.User.ID, - statusID: Twitter.Entity.V2.Tweet.ID - ) -> URL { - return Twitter.API.endpointV2URL - .appendingPathComponent("users") - .appendingPathComponent(userID) - .appendingPathComponent("likes") - .appendingPathComponent(statusID) - } - - public static func undoLike( - session: URLSession, - userID: Twitter.Entity.V2.User.ID, - statusID: Twitter.Entity.V2.Tweet.ID, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: undoLikeEndpointURL(userID: userID, statusID: statusID), - method: .DELETE, - query: nil, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: LikeContent.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+User+List.swift b/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+User+List.swift deleted file mode 100644 index 7007a6c0..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+User+List.swift +++ /dev/null @@ -1,243 +0,0 @@ -// -// Twitter+API+Users+List.swift -// -// -// Created by MainasuK on 2022-2-28. -// - -import Foundation - -extension Twitter.API.V2.User { - public enum List { } -} - -// https://developer.twitter.com/en/docs/twitter-api/lists/list-lookup/api-reference/get-users-id-owned_lists -extension Twitter.API.V2.User.List { - - private static func ownedListEndpointURL(userID: Twitter.Entity.V2.User.ID) -> URL { - return Twitter.API.endpointV2URL - .appendingPathComponent("users") - .appendingPathComponent(userID) - .appendingPathComponent("owned_lists") - } - - public static func onwedLists( - session: URLSession, - userID: Twitter.Entity.V2.User.ID, - query: OwnedListsQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: ownedListEndpointURL(userID: userID), - method: .GET, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: OwnedListsContent.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - public struct OwnedListsQuery: Query { - public let maxResults: Int - public let nextToken: String? - - public init( - maxResults: Int = 20, - nextToken: String? - ) { - self.maxResults = min(100, max(10, maxResults)) - self.nextToken = nextToken - } - - var queryItems: [URLQueryItem]? { - var items: [URLQueryItem] = [ - [Twitter.Request.Expansions.ownerID].queryItem, - Twitter.Request.userFields.queryItem, - Twitter.Request.listFields.queryItem, - URLQueryItem(name: "max_results", value: String(maxResults)), - ] - if let nextToken = nextToken { - let item = URLQueryItem(name: "pagination_token", value: nextToken) - items.append(item) - } - return items - } - var encodedQueryItems: [URLQueryItem]? { nil } - var formQueryItems: [URLQueryItem]? { nil } - var contentType: String? { nil } - var body: Data? { nil } - } - - public struct OwnedListsContent: Codable { - public let data: [Twitter.Entity.V2.List]? - public let includes: Includes? - public let meta: Meta - - public struct Includes: Codable { - public let users: [Twitter.Entity.V2.User] - } - - public struct Meta: Codable { - public let resultCount: Int - public let previousToken: String? - public let nextToken: String? - - enum CodingKeys: String, CodingKey { - case resultCount = "result_count" - case previousToken = "previous_token" - case nextToken = "next_token" - } - } - } - -} - -// https://developer.twitter.com/en/docs/twitter-api/lists/list-follows/api-reference/get-users-id-followed_lists -extension Twitter.API.V2.User.List { - - private static func followedListEndpointURL(userID: Twitter.Entity.V2.User.ID) -> URL { - return Twitter.API.endpointV2URL - .appendingPathComponent("users") - .appendingPathComponent(userID) - .appendingPathComponent("followed_lists") - } - - public static func followedLists( - session: URLSession, - userID: Twitter.Entity.V2.User.ID, - query: FollowedListsQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: followedListEndpointURL(userID: userID), - method: .GET, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: FollowedListsContent.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - public typealias FollowedListsQuery = OwnedListsQuery - public typealias FollowedListsContent = OwnedListsContent - -} - -// https://developer.twitter.com/en/docs/twitter-api/lists/list-members/api-reference/get-users-id-list_memberships -extension Twitter.API.V2.User.List { - - private static func membershipsEndpointURL(userID: Twitter.Entity.V2.User.ID) -> URL { - return Twitter.API.endpointV2URL - .appendingPathComponent("users") - .appendingPathComponent(userID) - .appendingPathComponent("list_memberships") - } - - public static func listMemberships( - session: URLSession, - userID: Twitter.Entity.V2.User.ID, - query: ListMembershipsQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: membershipsEndpointURL(userID: userID), - method: .GET, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: ListMembershipsContent.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - public typealias ListMembershipsQuery = OwnedListsQuery - public typealias ListMembershipsContent = OwnedListsContent - -} - -// https://developer.twitter.com/en/docs/twitter-api/lists/list-follows/api-reference/post-users-id-followed-lists -// https://developer.twitter.com/en/docs/twitter-api/lists/list-follows/api-reference/delete-users-id-followed-lists-list_id -extension Twitter.API.V2.User.List { - - private static func followEndpointURL(userID: Twitter.Entity.V2.User.ID) -> URL { - return Twitter.API.endpointV2URL - .appendingPathComponent("users") - .appendingPathComponent(userID) - .appendingPathComponent("followed_lists") - } - - private static func unfollowEndpointURL( - userID: Twitter.Entity.V2.User.ID, - listID: Twitter.Entity.V2.List.ID - ) -> URL { - return Twitter.API.endpointV2URL - .appendingPathComponent("users") - .appendingPathComponent(userID) - .appendingPathComponent("followed_lists") - .appendingPathComponent(listID) - } - - public static func follow( - session: URLSession, - userID: Twitter.Entity.V2.User.ID, - query: FollowQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: followEndpointURL(userID: userID), - method: .POST, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: FollowContent.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - public static func unfollow( - session: URLSession, - userID: Twitter.Entity.V2.User.ID, - listID: Twitter.Entity.V2.List.ID, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: unfollowEndpointURL(userID: userID, listID: listID), - method: .DELETE, - query: nil, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: FollowContent.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - public struct FollowQuery: JSONEncodeQuery { - - public let id: Twitter.Entity.V2.List.ID - - enum CodingKeys: String, CodingKey { - case id = "list_id" - } - - public init( - id: Twitter.Entity.V2.List.ID - ) { - self.id = id - } - - var queryItems: [URLQueryItem]? { nil } - var encodedQueryItems: [URLQueryItem]? { nil } - var formQueryItems: [URLQueryItem]? { nil } - } - - public struct FollowContent: Codable { - public let data: ContentData - - public struct ContentData: Codable { - public let following: Bool - } - } - -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+User+Lookup.swift b/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+User+Lookup.swift deleted file mode 100644 index 63d734e2..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+User+Lookup.swift +++ /dev/null @@ -1,257 +0,0 @@ -// -// Twitter+API+V2+UserLookup.swift -// -// -// Created by Cirno MainasuK on 2020-11-27. -// - -import Foundation -import Combine - -extension Twitter.API.V2.User { - public enum Lookup { } -} - -extension Twitter.API.V2.User.Lookup { - - private static let usersEndpointURL = Twitter.API.endpointV2URL.appendingPathComponent("users") - - public static func users( - session: URLSession, - userIDs: [Twitter.Entity.User.ID], - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let query = UserIDLookupQuery(userIDs: userIDs) - let request = Twitter.API.request( - url: usersEndpointURL, - method: .GET, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: Twitter.API.V2.User.Lookup.Content.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - public struct UserIDLookupQuery: Query { - public let userIDs: [Twitter.Entity.V2.User.ID] - - public init(userIDs: [Twitter.Entity.V2.User.ID]) { - self.userIDs = userIDs - } - - var queryItems: [URLQueryItem]? { - let userIDs = userIDs.joined(separator: ",") - let expansions: [Twitter.Request.Expansions] = [.pinnedTweetID] - let tweetsFields: [Twitter.Request.TwitterFields] = [ - .attachments, - .authorID, - .contextAnnotations, - .conversationID, - .created_at, - .entities, - .geo, - .id, - .inReplyToUserID, - .lang, - .publicMetrics, - .possiblySensitive, - .referencedTweets, - .source, - .text, - .withheld, - ] - let userFields: [Twitter.Request.UserFields] = [ - .createdAt, - .description, - .entities, - .id, - .location, - .name, - .pinnedTweetID, - .profileImageURL, - .protected, - .publicMetrics, - .url, - .username, - .verified, - .withheld - ] - return [ - expansions.queryItem, - tweetsFields.queryItem, - userFields.queryItem, - URLQueryItem(name: "ids", value: userIDs), - ] - } - var encodedQueryItems: [URLQueryItem]? { nil } - var formQueryItems: [URLQueryItem]? { nil } - var contentType: String? { nil } - var body: Data? { nil } - } - -} - -extension Twitter.API.V2.User.Lookup { - - private static let usersByEndpointURL = Twitter.API.endpointV2URL.appendingPathComponent("users/by") - - public static func users( - session: URLSession, - usernames: [String], - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let query = UsernameLookupQuery(usernames: usernames) - let request = Twitter.API.request( - url: usersByEndpointURL, - method: .GET, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: Twitter.API.V2.User.Lookup.Content.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - public struct UsernameLookupQuery: Query { - public let usernames: [String] - - public init(usernames: [String]) { - self.usernames = usernames - } - - var queryItems: [URLQueryItem]? { - let usernames = usernames.joined(separator: ",") - let expansions: [Twitter.Request.Expansions] = [.pinnedTweetID] - let tweetsFields: [Twitter.Request.TwitterFields] = [ - .attachments, - .authorID, - .contextAnnotations, - .conversationID, - .created_at, - .entities, - .geo, - .id, - .inReplyToUserID, - .lang, - .publicMetrics, - .possiblySensitive, - .referencedTweets, - .source, - .text, - .withheld, - ] - let userFields: [Twitter.Request.UserFields] = [ - .createdAt, - .description, - .entities, - .id, - .location, - .name, - .pinnedTweetID, - .profileImageURL, - .protected, - .publicMetrics, - .url, - .username, - .verified, - .withheld - ] - return [ - expansions.queryItem, - tweetsFields.queryItem, - userFields.queryItem, - URLQueryItem(name: "usernames", value: usernames), - ] - } - var encodedQueryItems: [URLQueryItem]? { nil } - var formQueryItems: [URLQueryItem]? { nil } - var contentType: String? { nil } - var body: Data? { nil } - } - -} - -// https://developer.twitter.com/en/docs/twitter-api/users/lookup/api-reference/get-users-me -extension Twitter.API.V2.User.Lookup { - - private static let meEndpointURL = Twitter.API.endpointV2URL.appendingPathComponent("users/me") - - public static func me( - session: URLSession, - authorization: Twitter.API.V2.OAuth2.Authorization - ) async throws -> Twitter.Response.Content { - let query = MeLookupQuery() - let request = Twitter.API.request( - url: meEndpointURL, - method: .GET, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: MeLookupContent.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - public struct MeLookupQuery: Query { - - var queryItems: [URLQueryItem]? { - let expansions: [Twitter.Request.Expansions] = [.pinnedTweetID] - let tweetsFields: [Twitter.Request.TwitterFields] = [ - .attachments, - .authorID, - .contextAnnotations, - .conversationID, - .created_at, - .entities, - .geo, - .id, - .inReplyToUserID, - .lang, - .publicMetrics, - .possiblySensitive, - .referencedTweets, - .source, - .text, - .withheld, - ] - let userFields: [Twitter.Request.UserFields] = [ - .createdAt, - .description, - .entities, - .id, - .location, - .name, - .pinnedTweetID, - .profileImageURL, - .protected, - .publicMetrics, - .url, - .username, - .verified, - .withheld - ] - return [ - expansions.queryItem, - tweetsFields.queryItem, - userFields.queryItem, - ] - } - var encodedQueryItems: [URLQueryItem]? { nil } - var formQueryItems: [URLQueryItem]? { nil } - var contentType: String? { nil } - var body: Data? { nil } - } - - public struct MeLookupContent: Codable { - public let data: Twitter.Entity.V2.User - } - -} - -extension Twitter.API.V2.User.Lookup { - public struct Content: Codable { - public let data: [Twitter.Entity.V2.User]? - public let errors: [Twitter.Response.V2.ContentError]? - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+User+Mute.swift b/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+User+Mute.swift deleted file mode 100644 index 98e0d370..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+User+Mute.swift +++ /dev/null @@ -1,99 +0,0 @@ -// -// Twitter+API+V2+User+Mute.swift -// -// -// Created by MainasuK on 2021-12-6. -// - -import Foundation - -extension Twitter.API.V2.User { - public enum Mute { } -} - -// doc: https://developer.twitter.com/en/docs/twitter-api/users/mutes/introduction -extension Twitter.API.V2.User.Mute { - - static func muteEndpointURL(sourceUserID: Twitter.Entity.User.ID) -> URL { - return Twitter.API.endpointV2URL - .appendingPathComponent("users") - .appendingPathComponent(sourceUserID) - .appendingPathComponent("muting") - } - - public static func mute( - session: URLSession, - sourceUserID: Twitter.Entity.User.ID, - query: MuteQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: muteEndpointURL(sourceUserID: sourceUserID), - method: .POST, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: MuteContent.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - public struct MuteQuery: JSONEncodeQuery { - - public let targetUserID: Twitter.Entity.User.ID - - public init(targetUserID: Twitter.Entity.User.ID) { - self.targetUserID = targetUserID - } - - enum CodingKeys: String, CodingKey { - case targetUserID = "target_user_id" - } - - var queryItems: [URLQueryItem]? { nil } - var encodedQueryItems: [URLQueryItem]? { nil } - var formQueryItems: [URLQueryItem]? { nil } - } - - public struct MuteContent: Codable { - public let data: ContentData - - public struct ContentData: Codable { - public let muting: Bool - } - } - -} - -extension Twitter.API.V2.User.Mute { - - static func unmuteEndpointURL( - sourceUserID: Twitter.Entity.User.ID, - targetUserID: Twitter.Entity.User.ID - ) -> URL { - return Twitter.API.endpointV2URL - .appendingPathComponent("users") - .appendingPathComponent(sourceUserID) - .appendingPathComponent("muting") - .appendingPathComponent(targetUserID) - } - - public static func unmute( - session: URLSession, - sourceUserID: Twitter.Entity.User.ID, - targetUserID: Twitter.Entity.User.ID, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let url = unmuteEndpointURL(sourceUserID: sourceUserID, targetUserID: targetUserID) - let request = Twitter.API.request( - url: url, - method: .DELETE, - query: nil, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: MuteContent.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+User+Retweet.swift b/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+User+Retweet.swift deleted file mode 100644 index 88e50dc4..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+User+Retweet.swift +++ /dev/null @@ -1,98 +0,0 @@ -// -// Twitter+API+V2+User+Retweet.swift -// Twitter+API+V2+User+Retweet -// -// Created by Cirno MainasuK on 2021-9-8. -// - -import Foundation - -extension Twitter.API.V2.User { - public enum Retweet { } -} - -extension Twitter.API.V2.User.Retweet { - - static func retweetEndpointURL( - userID: Twitter.Entity.V2.User.ID - ) -> URL { - return Twitter.API.endpointV2URL - .appendingPathComponent("users") - .appendingPathComponent(userID) - .appendingPathComponent("retweets") - } - - public static func retweet( - session: URLSession, - query: RetweetQuery, - userID: Twitter.Entity.V2.User.ID, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: retweetEndpointURL(userID: userID), - method: .POST, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: RetweetContent.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - public struct RetweetQuery: JSONEncodeQuery { - public let tweetID: Twitter.Entity.V2.Tweet.ID - - enum CodingKeys: String, CodingKey { - case tweetID = "tweet_id" - } - - public init(tweetID: Twitter.Entity.V2.Tweet.ID) { - self.tweetID = tweetID - } - - var queryItems: [URLQueryItem]? { nil } - var encodedQueryItems: [URLQueryItem]? { nil } - var formQueryItems: [URLQueryItem]? { nil } - } - - public struct RetweetContent: Codable { - public let data: ContentData - - public struct ContentData: Codable { - public let retweeted: Bool - } - } - -} - -extension Twitter.API.V2.User.Retweet { - - static func undoRetweetEndpointURL( - userID: Twitter.Entity.V2.User.ID, - statusID: Twitter.Entity.V2.Tweet.ID - ) -> URL { - return Twitter.API.endpointV2URL - .appendingPathComponent("users") - .appendingPathComponent(userID) - .appendingPathComponent("retweets") - .appendingPathComponent(statusID) - } - - public static func undoRetweet( - session: URLSession, - userID: Twitter.Entity.V2.User.ID, - statusID: Twitter.Entity.V2.Tweet.ID, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: undoRetweetEndpointURL(userID: userID, statusID: statusID), - method: .DELETE, - query: nil, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: RetweetContent.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+User+Timeline.swift b/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+User+Timeline.swift deleted file mode 100644 index d76993d6..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+User+Timeline.swift +++ /dev/null @@ -1,211 +0,0 @@ -// -// Twitter+API+V2+User+Timeline.swift -// -// -// Created by MainasuK on 2022-6-6. -// - -import Foundation - -extension Twitter.API.V2.User { - public enum Timeline { } -} - -// Home -// https://developer.twitter.com/en/docs/twitter-api/tweets/timelines/api-reference/get-users-id-reverse-chronological -extension Twitter.API.V2.User.Timeline { - - private static func homeTimelineEndpointURL(userID: Twitter.Entity.V2.User.ID) -> URL { - return Twitter.API.endpointV2URL - .appendingPathComponent("users") - .appendingPathComponent(userID) - .appendingPathComponent("timelines") - .appendingPathComponent("reverse_chronological") - } - - public static func home( - session: URLSession, - userID: Twitter.Entity.V2.User.ID, - query: HomeQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: homeTimelineEndpointURL(userID: userID), - method: .GET, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: HomeContent.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - static var homeQueryExpansions: [Twitter.Request.Expansions] { - return [ - .attachmentsPollIDs, - .attachmentsMediaKeys, - .authorID, - .entitiesMentionsUsername, - .geoPlaceID, - .inReplyToUserID, - .referencedTweetsID, - .referencedTweetsIDAuthorID - ] - } - - public struct HomeQuery: Query { - - public let sinceID: Twitter.Entity.V2.Tweet.ID? - public let untilID: Twitter.Entity.V2.Tweet.ID? - public let paginationToken: String? - public let maxResults: Int? - - public init( - sinceID: Twitter.Entity.V2.Tweet.ID?, - untilID: Twitter.Entity.V2.Tweet.ID?, - paginationToken: String?, - maxResults: Int? - ) { - self.sinceID = sinceID - self.untilID = untilID - self.paginationToken = paginationToken - self.maxResults = maxResults - } - - var queryItems: [URLQueryItem]? { - var queryItems: [URLQueryItem] = [ - Twitter.API.V2.User.Timeline.homeQueryExpansions.queryItem, - Twitter.Request.tweetsFields.queryItem, - Twitter.Request.userFields.queryItem, - Twitter.Request.mediaFields.queryItem, - Twitter.Request.placeFields.queryItem, - Twitter.Request.pollFields.queryItem, - ] - sinceID.flatMap { - queryItems.append(URLQueryItem(name: "since_id", value: $0)) - } - untilID.flatMap { - queryItems.append(URLQueryItem(name: "until_id", value: $0)) - } - paginationToken.flatMap { - queryItems.append(URLQueryItem(name: "pagination_token", value: $0)) - } - maxResults.flatMap { - queryItems.append(URLQueryItem(name: "max_results", value: String($0))) - } - guard !queryItems.isEmpty else { return nil } - return queryItems - } - var encodedQueryItems: [URLQueryItem]? { nil } - var formQueryItems: [URLQueryItem]? { nil } - var contentType: String? { nil } - var body: Data? { nil } - } - - public struct HomeContent: Codable { - public let data: [Twitter.Entity.V2.Tweet]? - public let includes: Includes? - public let meta: Meta - - public struct Includes: Codable { - public let tweets: [Twitter.Entity.V2.Tweet]? - public let users: [Twitter.Entity.V2.User]? - public let media: [Twitter.Entity.V2.Media]? - public let places: [Twitter.Entity.V2.Place]? - public let polls: [Twitter.Entity.V2.Tweet.Poll]? - } - - public struct Meta: Codable { - public let resultCount: Int - public let newestID: Twitter.Entity.V2.Tweet.ID? - public let oldestID: Twitter.Entity.V2.Tweet.ID? - public let previousToken: String? - public let nextToken: String? - - enum CodingKeys: String, CodingKey { - case resultCount = "result_count" - case newestID = "newest_id" - case oldestID = "oldest_id" - case previousToken = "previous_token" - case nextToken = "next_token" - } - } - } - -} - -// Tweets -// https://developer.twitter.com/en/docs/twitter-api/tweets/timelines/api-reference/get-users-id-tweets -extension Twitter.API.V2.User.Timeline { - - private static func tweetsTimelineEndpointURL(userID: Twitter.Entity.V2.User.ID) -> URL { - return Twitter.API.endpointV2URL - .appendingPathComponent("users") - .appendingPathComponent(userID) - .appendingPathComponent("tweets") - } - - public static func tweets( - session: URLSession, - userID: Twitter.Entity.V2.User.ID, - query: TweetsQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: tweetsTimelineEndpointURL(userID: userID), - method: .GET, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: HomeContent.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - static var tweetsQueryExpansions: [Twitter.Request.Expansions] { - return homeQueryExpansions - } - - public typealias TweetsQuery = HomeQuery - - public typealias TweetsContent = HomeContent - -} - -// Likes -// https://developer.twitter.com/en/docs/twitter-api/tweets/likes/api-reference/get-users-id-liked_tweets -extension Twitter.API.V2.User.Timeline { - - private static func likedTweetsTimelineEndpointURL(userID: Twitter.Entity.V2.User.ID) -> URL { - return Twitter.API.endpointV2URL - .appendingPathComponent("users") - .appendingPathComponent(userID) - .appendingPathComponent("liked_tweets") - } - - public static func likes( - session: URLSession, - userID: Twitter.Entity.V2.User.ID, - query: TweetsQuery, - authorization: Twitter.API.OAuth.Authorization - ) async throws -> Twitter.Response.Content { - let request = Twitter.API.request( - url: likedTweetsTimelineEndpointURL(userID: userID), - method: .GET, - query: query, - authorization: authorization - ) - let (data, response) = try await session.data(for: request, delegate: nil) - let value = try Twitter.API.decode(type: HomeContent.self, from: data, response: response) - return Twitter.Response.Content(value: value, response: response) - } - - static var likesQueryExpansions: [Twitter.Request.Expansions] { - return homeQueryExpansions - } - - public typealias LikesQuery = HomeQuery - - public typealias LikesContent = HomeContent - -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+User.swift b/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+User.swift deleted file mode 100644 index fcf9b7b6..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2+User.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// Twitter+API+V2+User.swift -// -// -// Created by MainasuK on 2022-4-21. -// - -import Foundation - -extension Twitter.API.V2 { - public enum User { } -} diff --git a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2.swift b/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2.swift deleted file mode 100644 index 48d824d1..00000000 --- a/TwidereSDK/Sources/TwitterSDK/API/V2/Twitter+API+V2.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// Twitter+API+V2.swift -// -// -// Created by MainasuK on 2022-4-21. -// - -import Foundation - -extension Twitter.API { - public enum V2 { } -} diff --git a/TwidereSDK/Sources/TwitterSDK/AuthorizationContext/Twitter+AuthorizationContext+OAuth.swift b/TwidereSDK/Sources/TwitterSDK/AuthorizationContext/Twitter+AuthorizationContext+OAuth.swift deleted file mode 100644 index c7c56c4c..00000000 --- a/TwidereSDK/Sources/TwitterSDK/AuthorizationContext/Twitter+AuthorizationContext+OAuth.swift +++ /dev/null @@ -1,79 +0,0 @@ -// -// Twitter+AuthorizationContext+OAuth.swift -// -// -// Created by MainasuK on 2022-4-24. -// - -import Foundation -import CryptoKit - -extension Twitter.AuthorizationContext { - public enum OAuth { - public enum Standard { } - public enum Relay { } - } -} - -extension Twitter.AuthorizationContext.OAuth { - public enum Context { - case standard(Standard.Context) - case relay(Relay.Context) - - public enum RequestTokenResponse { - case standard(Twitter.API.OAuth.RequestToken.Standard.Response) - case relay(Twitter.API.OAuth.RequestToken.Relay.Response) - } - - public func requestToken(session: URLSession) async throws -> RequestTokenResponse { - switch self { - case .standard(let context): - let response = try await Twitter.API.OAuth.RequestToken.Standard.requestToken( - session: session, - query: .init( - consumerKey: context.consumerKey, - consumerKeySecret: context.consumerKeySecret - ) - ) - return .standard(response) - case .relay(let context): - let response = try await Twitter.API.OAuth.RequestToken.Relay.requestToken( - session: session, - query: .init( - consumerKey: context.consumerKey, - hostPublicKey: context.hostPublicKey, - oauthEndpoint: context.oauthEndpoint - ) - ) - return .relay(response) - } - } - } -} - -extension Twitter.AuthorizationContext.OAuth.Standard { - public struct Context { - public let consumerKey: String - public let consumerKeySecret: String - - public init(consumerKey: String, consumerKeySecret: String) { - self.consumerKey = consumerKey - self.consumerKeySecret = consumerKeySecret - } - } -} - -extension Twitter.AuthorizationContext.OAuth.Relay { - public struct Context { - public let consumerKey: String - public let hostPublicKey: Curve25519.KeyAgreement.PublicKey - public let oauthEndpoint: String - - public init(consumerKey: String, hostPublicKey: Curve25519.KeyAgreement.PublicKey, oauthEndpoint: String) { - self.consumerKey = consumerKey - self.hostPublicKey = hostPublicKey - self.oauthEndpoint = oauthEndpoint - } - } -} - diff --git a/TwidereSDK/Sources/TwitterSDK/AuthorizationContext/Twitter+AuthorizationContext+OAuth2.swift b/TwidereSDK/Sources/TwitterSDK/AuthorizationContext/Twitter+AuthorizationContext+OAuth2.swift deleted file mode 100644 index f788f9a2..00000000 --- a/TwidereSDK/Sources/TwitterSDK/AuthorizationContext/Twitter+AuthorizationContext+OAuth2.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// Twitter+AuthorizationContext+OAuth2.swift -// -// -// Created by MainasuK on 2022-4-24. -// - -import Foundation -import CryptoKit - -extension Twitter.AuthorizationContext { - public enum OAuth2 { - public enum Relay { } - } -} - -extension Twitter.AuthorizationContext.OAuth2 { - public enum Context { - case relay(Relay.Context) - } -} - -extension Twitter.AuthorizationContext.OAuth2.Relay { - public typealias Context = Twitter.API.V2.OAuth2.Authorize.Relay.Query - public typealias Response = Twitter.API.V2.OAuth2.Authorize.Relay.Response -} - -extension Twitter.AuthorizationContext.OAuth2.Relay.Context { - public func authorize(session: URLSession) async throws -> Twitter.AuthorizationContext.OAuth2.Relay.Response { - return try await Twitter.API.V2.OAuth2.Authorize.Relay.authorize( - session: session, - query: self - ) - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/AuthorizationContext/Twitter+AuthorizationContext.swift b/TwidereSDK/Sources/TwitterSDK/AuthorizationContext/Twitter+AuthorizationContext.swift deleted file mode 100644 index b788dd11..00000000 --- a/TwidereSDK/Sources/TwitterSDK/AuthorizationContext/Twitter+AuthorizationContext.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// Twitter+AuthorizationContext.swift -// -// -// Created by MainasuK on 2022-4-24. -// - -import Foundation - -extension Twitter { - public enum AuthorizationContext { - case oauth(OAuth.Context) - case oauth2(OAuth2.Context) - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Coordinates.swift b/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Coordinates.swift deleted file mode 100644 index 425c081a..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Coordinates.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// Twitter+Coordinates.swift -// TwitterAPI -// -// Created by Cirno MainasuK on 2020-9-16. -// - -import Foundation - -extension Twitter.Entity { - public struct Coordinates: Codable { - public var type: String - public var coordinates: [Double] - } -} - -extension Twitter.Entity.Coordinates: Equatable { } diff --git a/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+ExtendedEntities.swift b/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+ExtendedEntities.swift deleted file mode 100644 index a4de3f5e..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+ExtendedEntities.swift +++ /dev/null @@ -1,173 +0,0 @@ -// -// Twitter+ExtendedEntities.swift -// TwitterAPI -// -// Created by Cirno MainasuK on 2020-9-3. -// - -import Foundation -import CoreGraphics - -extension Twitter.Entity { - public struct ExtendedEntities: Codable { - public let media: [Media]? - - } -} - -extension Twitter.Entity.ExtendedEntities: Equatable { } - -extension Twitter.Entity.ExtendedEntities { - public struct Media: Codable { - public let id: Double? - public let idStr: String? - public let indices: [Int]? - public let mediaURL: String? - public let mediaURLHTTPS: String? - public let url: String? - public let displayURL: String? - public let expandedURL: String? - public let type: String? - public let sizes: Sizes? - public let videoInfo: VideoInfo? - public let sourceStatusID: Double? - public let sourceStatusIDStr: String? - public let sourceUserID: Int? - public let sourceUserIDStr: String? - public let extAltText: String? - - enum CodingKeys: String, CodingKey { - case id = "id" - case idStr = "id_str" - case indices = "indices" - case mediaURL = "media_url" - case mediaURLHTTPS = "media_url_https" - case url = "url" - case displayURL = "display_url" - case expandedURL = "expanded_url" - case type = "type" - case sizes = "sizes" - case videoInfo = "video_info" - case sourceStatusID = "source_status_id" - case sourceStatusIDStr = "source_status_id_str" - case sourceUserID = "source_user_id" - case sourceUserIDStr = "source_user_id_str" - case extAltText = "ext_alt_text" - } - } -} - -extension Twitter.Entity.ExtendedEntities.Media: Equatable { } - - -extension Twitter.Entity.ExtendedEntities.Media { - - public struct Sizes: Codable { - public let thumbnail: Size? - public let small: Size? - public let medium: Size? - public let large: Size? - - enum CodingKeys: String, CodingKey { - case thumbnail = "thumb" - case small = "small" - case medium = "medium" - case large = "large" - } - - public enum SizeKind: String { - case thumbnail = "thumb" - case small - case medium - case large - } - - public func size(kind: SizeKind) -> Size? { - switch kind { - case .thumbnail: return thumbnail - case .small: return small - case .medium: return medium - case .large: return large - } - } - } - - public struct Size: Codable { - public let w: Int? - public let h: Int? - public let resize: String? - - enum CodingKeys: String, CodingKey { - case w = "w" - case h = "h" - case resize = "resize" - } - - public init(w: Int?, h: Int?, resize: String?) { - self.w = w - self.h = h - self.resize = resize - } - } - - public enum Resize: String { - case fit - case crop - } - - public struct VideoInfo: Codable { - public let durationMillis: Int? - public let variants: [Variant]? - - enum CodingKeys: String, CodingKey { - case durationMillis = "duration_millis" - case variants - } - - public struct Variant: Codable { - public let bitrate: Int? - public let contentType: String - public let url: String - - enum CodingKeys: String, CodingKey { - case bitrate - case contentType = "content_type" - case url - } - } - } - -} - -extension Twitter.Entity.ExtendedEntities.Media.Sizes: Equatable { } -extension Twitter.Entity.ExtendedEntities.Media.Size: Equatable { } -extension Twitter.Entity.ExtendedEntities.Media.VideoInfo: Equatable { } -extension Twitter.Entity.ExtendedEntities.Media.VideoInfo.Variant: Equatable { } - -extension Twitter.Entity.ExtendedEntities.Media { - - public var assetURL: String? { - switch type { - case "animated_gif": return videoInfo?.variants?.max(by: { ($0.bitrate ?? 0) < ($1.bitrate ?? 0) })?.url - case "video": return videoInfo?.variants?.max(by: { ($0.bitrate ?? 0) < ($1.bitrate ?? 0) })?.url - case "photo": return mediaURLHTTPS - default: return nil - } - } - - public var previewImageURL: String? { - switch type { - case "animated_gif": return mediaURLHTTPS - case "video": return mediaURLHTTPS - default: return nil - } - } - - public var durationMS: Int? { - switch type { - case "video": return videoInfo?.durationMillis - default: return nil - } - } - -} diff --git a/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+List.swift b/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+List.swift deleted file mode 100644 index 3f8b3741..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+List.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// Twitter+Entity+List.swift -// -// -// Created by MainasuK on 2022-3-10. -// - -import Foundation - -// Note: -// use the v2 as the persist model -// this model is for query following relationship only -extension Twitter.Entity { - public struct List: Codable { - public typealias ID = String - - public let id: ID - - public let name: String - public let uri: String - public let following: Bool - - - enum CodingKeys: String, CodingKey { - case id = "id_str" - - case name - case uri - case following - } - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+Place.swift b/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+Place.swift deleted file mode 100644 index 55207202..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+Place.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// Twitter+Entity+Place.swift -// TwitterAPI -// -// Created by Cirno MainasuK on 2020-9-3. -// - -import Foundation - -extension Twitter.Entity { - public struct Place: Codable { - public typealias ID = String - public let id: ID - - public let country: String? - public let countryCode: String? - public let fullName: String? - public let name: String? - public let placeType: String? - public let url: String? - - enum CodingKeys: String, CodingKey { - case id = "id" - - case country = "country" - case countryCode = "country_code" - case fullName = "full_name" - case name = "name" - case placeType = "place_type" - case url = "url" - } - } -} - -extension Twitter.Entity.Place: Equatable { } diff --git a/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+QuotedStatus.swift b/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+QuotedStatus.swift deleted file mode 100644 index 7a7fbc70..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+QuotedStatus.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// Twitter+QuotedStatus.swift -// TwitterAPI -// -// Created by Cirno MainasuK on 2020-9-11. -// - -import Foundation - -extension Twitter.Entity { - /// https://developer.twitter.com/en/docs/twitter-api/v1/data-dictionary/overview/intro-to-tweet-json#quotetweet - public class QuotedStatus: Twitter.Entity.Tweet { } -} diff --git a/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+RateLimitStatus.swift b/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+RateLimitStatus.swift deleted file mode 100644 index e87d4bc6..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+RateLimitStatus.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// Twitter+Entity+RateLimitStatus.swift -// -// -// Created by Cirno MainasuK on 2020-12-7. -// - -import Foundation -import SwiftyJSON - -extension Twitter.Entity { - /// https://developer.twitter.com/en/docs/twitter-api/v1/developer-utilities/rate-limit-status/overview - public struct RateLimitStatus: Codable { - - public let rateLimitContext: RateLimitContext - public let resources: JSON - - enum CodingKeys: String, CodingKey { - case rateLimitContext = "rate_limit_context" - case resources - } - - public struct RateLimitContext: Codable { - public let accessToken: String - enum CodingKeys: String, CodingKey { - case accessToken = "access_token" - } - } - } -} - -extension Twitter.Entity.RateLimitStatus { - public struct Status: Codable { - public let limit: Int - public let remaining: Int - public let reset: Int - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+Relationship.swift b/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+Relationship.swift deleted file mode 100644 index 63f73617..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+Relationship.swift +++ /dev/null @@ -1,99 +0,0 @@ -// -// Twitter+Entity+Relationship.swift -// -// -// Created by Cirno MainasuK on 2020-12-23. -// - -import Foundation - -extension Twitter.Entity { - - // doc: https://developer.twitter.com/en/docs/twitter-api/v1/accounts-and-users/follow-search-get-users/api-reference/get-friendships-show - public struct Relationship: Codable { - public let source: RelationshipSource - public let target: RelationshipTarget - - enum CodingKeys: String, CodingKey { - case relationship - } - - enum RelationshipKeys: String, CodingKey { - case source - case target - } - - public init(from decoder: Decoder) throws { - let values = try decoder.container(keyedBy: CodingKeys.self) - let relationshipValues = try values.nestedContainer(keyedBy: RelationshipKeys.self, forKey: .relationship) - source = try relationshipValues.decode(RelationshipSource.self, forKey: .source) - target = try relationshipValues.decode(RelationshipTarget.self, forKey: .target) - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - var relationshipContainer = container.nestedContainer(keyedBy: RelationshipKeys.self, forKey: .relationship) - try relationshipContainer.encode(source, forKey: .source) - try relationshipContainer.encode(target, forKey: .target) - } - } - - public struct RelationshipSource: Codable { - public typealias ID = String - - public let idStr: ID - public let screenName: String - public let following: Bool - public let followedBy: Bool - public let liveFollowing: Bool? - public let followingReceived: Bool? - public let followingRequested: Bool? - public let notificationsEnabled: Bool? - public let canDM: Bool? - public let blocking: Bool? - public let blockedBy: Bool? - public let muting: Bool? - public let wantRetweets: Bool? - public let allReplies: Bool? - public let markedSpam: Bool? - - enum CodingKeys: String, CodingKey { - case idStr = "id_str" - case screenName = "screen_name" - case following = "following" - case followedBy = "followed_by" - case liveFollowing = "live_following" - case followingReceived = "following_received" - case followingRequested = "following_requested" - case notificationsEnabled = "notifications_enabled" - case canDM = "can_dm" - case blocking = "blocking" - case blockedBy = "blocked_by" - case muting = "muting" - case wantRetweets = "want_retweets" - case allReplies = "all_replies" - case markedSpam = "marked_spam" - } - } - - public struct RelationshipTarget: Codable { - public typealias ID = String - - public let idStr: ID - public let screenName: String - public let following: Bool - public let followedBy: Bool - public let followingReceived: Bool? - public let followingRequested: Bool? - - enum CodingKeys: String, CodingKey { - case idStr = "id_str" - case screenName = "screen_name" - case following = "following" - case followedBy = "followed_by" - case followingReceived = "following_received" - case followingRequested = "following_requested" - } - } - -} diff --git a/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+RetweetedStatus.swift b/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+RetweetedStatus.swift deleted file mode 100644 index b1fefb22..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+RetweetedStatus.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// Twitter+RetweetedStatus.swift -// TwitterAPI -// -// Created by Cirno MainasuK on 2020-9-3. -// - -import Foundation - -extension Twitter.Entity { - /// https://developer.twitter.com/en/docs/twitter-api/v1/data-dictionary/overview/intro-to-tweet-json#retweet - public class RetweetedStatus: Twitter.Entity.Tweet { } -} diff --git a/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+SavedSearch.swift b/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+SavedSearch.swift deleted file mode 100644 index 95cedd2a..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+SavedSearch.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// Twitter+Entity+SavedSearch.swift -// -// -// Created by MainasuK on 2021-12-22. -// - -import Foundation - -extension Twitter.Entity { - public struct SavedSearch: Codable { - public typealias ID = String - - public let idStr: ID - public let name: String - public let query: String - public let createdAt: Date - - enum CodingKeys: String, CodingKey { - case idStr = "id_str" - case name - case query - case createdAt = "created_at" - } - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+Trend+Place.swift b/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+Trend+Place.swift deleted file mode 100644 index 622b5ddf..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+Trend+Place.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// Twitter+Entity+Trend+Place.swift -// -// -// Created by MainasuK on 2022-4-15. -// - -import Foundation - -extension Twitter.Entity.Trend { - public struct Place: Codable, Identifiable { - public var id: Int { - return woeid - } - - public let name: String - public let woeid: Int - public let parentID: Int - - public let placeType: PlaceType? - public let country: String? - public let countryCode: String? - public let fullName: String? - public let url: String? - - enum CodingKeys: String, CodingKey { - case name - case woeid - case parentID = "parentid" - - case placeType = "place_type" - case country - case countryCode = "country_code" - case fullName = "full_name" - case url = "url" - } - - public struct PlaceType: Codable { - public let code: Int - public let name: String - } - } -} - -extension Twitter.Entity.Trend.Place: Hashable { - public static func == (lhs: Twitter.Entity.Trend.Place, rhs: Twitter.Entity.Trend.Place) -> Bool { - return lhs.woeid == rhs.woeid - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(woeid) - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+Trend.swift b/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+Trend.swift deleted file mode 100644 index d6c90a92..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+Trend.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// Twitter+Entity+Trend.swift -// -// -// Created by MainasuK on 2021-12-27. -// - -import Foundation - -extension Twitter.Entity { - public struct Trend: Codable, Hashable { - public let name: String - public let url: String - public let query: String - public let tweetVolume: Int? - - enum CodingKeys: String, CodingKey { - case name = "name" - case url = "url" - case query = "query" - case tweetVolume = "tweet_volume" - } - - public init( - name: String, - url: String, - query: String, - tweetVolume: Int? - ) { - self.name = name - self.url = url - self.query = query - self.tweetVolume = tweetVolume - } - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+Tweet+Entities.swift b/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+Tweet+Entities.swift deleted file mode 100644 index 72270098..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+Tweet+Entities.swift +++ /dev/null @@ -1,123 +0,0 @@ -// -// Twitter+Entity+Tweet+Entities.swift -// TwitterAPI -// -// Created by Cirno MainasuK on 2020-9-3. -// - -import Foundation - -extension Twitter.Entity.Tweet { - public struct Entities: Codable { - public let symbols: [Symbol]? - public let userMentions: [UserMention]? - public let urls: [URL]? - public let hashtags: [Hashtag]? - public let polls: [Poll]? - - public enum CodingKeys: String, CodingKey { - case symbols = "symbols" - case userMentions = "user_mentions" - case urls = "urls" - case hashtags = "hashtags" - case polls = "polls" - } - } -} - -extension Twitter.Entity.Tweet.Entities: Equatable { } - -extension Twitter.Entity.Tweet.Entities { - - public struct Symbol: Codable { - public let text: String? - public let indices: [Int]? - - enum CodingKeys: String, CodingKey { - case text = "text" - case indices = "indices" - } - } - - public struct UserMention: Codable { - public let screenName: String? /// username - public let name: String? /// nickname - public let id: Int? - public let idStr: String? - public let indices: [Int]? - - enum CodingKeys: String, CodingKey { - case screenName = "screen_name" - case name = "name" - case id = "id" - case idStr = "id_str" - case indices = "indices" - } - } - - public struct URL: Codable { - public let url: String? - public let expandedURL: String? - public let displayURL: String? - public let indices: [Int]? - - enum CodingKeys: String, CodingKey { - case url = "url" - case expandedURL = "expanded_url" - case displayURL = "display_url" - case indices = "indices" - } - } - - public struct Hashtag: Codable { - public let text: String? - public let indices: [Int]? - - enum CodingKeys: String, CodingKey { - case text = "text" - case indices = "indices" - } - } - - public struct Poll: Codable { - public let options: [Option]? - public let endDatetime: String? - public let durationMinutes: Int? - - enum CodingKeys: String, CodingKey { - case options = "options" - case endDatetime = "end_datetime" - case durationMinutes = "duration_minutes" - } - - public init(options: [Option]?, endDatetime: String?, durationMinutes: Int?) { - self.options = options - self.endDatetime = endDatetime - self.durationMinutes = durationMinutes - } - - // MARK: - Option - public struct Option: Codable { - public let position: Int? - public let text: String? - - enum CodingKeys: String, CodingKey { - case position = "position" - case text = "text" - } - - public init(position: Int?, text: String?) { - self.position = position - self.text = text - } - } - } - -} - -extension Twitter.Entity.Tweet.Entities.Symbol: Equatable { } -extension Twitter.Entity.Tweet.Entities.UserMention: Equatable { } -extension Twitter.Entity.Tweet.Entities.URL: Equatable { } -extension Twitter.Entity.Tweet.Entities.Hashtag: Equatable { } -extension Twitter.Entity.Tweet.Entities.Poll: Equatable { } -extension Twitter.Entity.Tweet.Entities.Poll.Option: Equatable { } diff --git a/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+Tweet.swift b/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+Tweet.swift deleted file mode 100644 index 761ae662..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+Tweet.swift +++ /dev/null @@ -1,105 +0,0 @@ -// -// Twitter+Tweet.swift -// TwitterAPI -// -// Created by Cirno MainasuK on 2020-9-3. -// - -import Foundation - -extension Twitter.Entity { - public class Tweet: Codable { - - public typealias ID = String - - // Fundamental - public let createdAt: Date - public let idStr: ID - - public let text: String? - public let fullText: String? - - public let user: User - public let entities: Entities - public let extendedEntities: ExtendedEntities? - - public let coordinates: Coordinates? - public let place: Place? - - //let contributors: JSONNull? - public let favorited: Bool? - public let favoriteCount: Int? - - public let retweeted: Bool? - public let retweetCount: Int? - public let retweetedStatus: RetweetedStatus? - - public let inReplyToScreenName: String? - //let inReplyToStatusID: JSONNull? - public let inReplyToStatusIDStr: ID? - //let inReplyToUserID: JSONNull? - public let inReplyToUserIDStr: User.ID? - //let isQuoteStatus: Bool - public let lang: String? - //let possiblySensitive: Bool? - //let possiblySensitiveAppealable: Bool? - - public let quotedStatusIDStr: String? - public let quotedStatus: QuotedStatus? - - public let source: String? - //let truncated: Bool - - public enum CodingKeys: String, CodingKey { - // Fundamental - case createdAt = "created_at" - case idStr = "id_str" - - case text = "text" - case fullText = "full_text" - - case user = "user" - case entities = "entities" - case extendedEntities = "extended_entities" - - case coordinates = "coordinates" - case place = "place" - - //case contributors = "contributors" - case favorited = "favorited" - case favoriteCount = "favorite_count" - - case retweeted = "retweeted" - case retweetCount = "retweet_count" - case retweetedStatus = "retweeted_status" - - case quotedStatusIDStr = "quoted_status_id_str" - case quotedStatus = "quoted_status" - - case inReplyToScreenName = "in_reply_to_screen_name" - //case inReplyToStatusID = "in_reply_to_status_id" - case inReplyToStatusIDStr = "in_reply_to_status_id_str" - //case inReplyToUserID = "in_reply_to_user_id" - case inReplyToUserIDStr = "in_reply_to_user_id_str" - //case isQuoteStatus = "is_quote_status" - case lang = "lang" - //case possiblySensitive = "possibly_sensitive" - //case possiblySensitiveAppealable = "possibly_sensitive_appealable" - - case source = "source" - //case truncated = "truncated" - } - } -} - -extension Twitter.Entity.Tweet: Hashable { - - public static func == (lhs: Twitter.Entity.Tweet, rhs: Twitter.Entity.Tweet) -> Bool { - lhs.idStr == rhs.idStr - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(idStr) - } - -} diff --git a/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+User+Entities.swift b/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+User+Entities.swift deleted file mode 100644 index 49e195f6..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+User+Entities.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// Twitter+Entity+User+Entities.swift -// -// -// Created by Cirno MainasuK on 2020-11-26. -// - -import Foundation - -extension Twitter.Entity.User { - public struct Entities: Codable { - // FIXME: - public let url: URL? - public let description: Description? - } -} - -extension Twitter.Entity.User.Entities: Equatable { } - -extension Twitter.Entity.User.Entities { - - public struct URL: Codable { - public let urls: [URLNode]? - } - - public struct Description: Codable { - public let urls: [URLNode]? - } - - public struct URLNode: Codable { - public let url: String? - public let expandedURL: String? - public let displayURL: String? - public let indices: [Int]? - - enum CodingKeys: String, CodingKey { - case url = "url" - case expandedURL = "expanded_url" - case displayURL = "display_url" - case indices = "indices" - } - } - -} - -extension Twitter.Entity.User.Entities.URL: Equatable { } -extension Twitter.Entity.User.Entities.Description: Equatable { } -extension Twitter.Entity.User.Entities.URLNode: Equatable { } diff --git a/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+User.swift b/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+User.swift deleted file mode 100644 index 8abacecb..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity+User.swift +++ /dev/null @@ -1,144 +0,0 @@ -// -// Twitter+User.swift -// TwitterAPI -// -// Created by Cirno MainasuK on 2020-9-3. -// - -import Foundation - -extension Twitter.Entity { - public struct User: Codable { - - public typealias ID = String - - // Fundamental - public let idStr: ID - // nickname - public let name: String - /// @username without "@" - public let screenName: String - - public let userDescription: String? - public let entities: Entities? - - public let location: String? - public let url: String? - public let protected: Bool? - - public let followersCount: Int? - public let friendsCount: Int? - public let listedCount: Int? - public let favouritesCount: Int? - public let statusesCount: Int? - - public let createdAt: Date? - - public let geoEnabled: Bool? - public let verified: Bool? - public let contributorsEnabled: Bool? - - public let profileImageURLHTTPS: String? - public let profileBannerURL: String? - - public let profileLinkColor: String? - public let profileSidebarBorderColor: String? - public let profileSidebarFillColor: String? - public let profileTextColor: String? - public let hasExtendedProfile: Bool? - public let defaultProfile: Bool? - public let defaultProfileImage: Bool? - public let following: Bool? - public let followRequestSent: Bool? - public let notifications: Bool? - - enum CodingKeys: String, CodingKey { - case idStr = "id_str" - - case name = "name" - case screenName = "screen_name" - - case userDescription = "description" - case entities = "entities" - - case url = "url" - case location = "location" - case protected = "protected" - - case followersCount = "followers_count" - case friendsCount = "friends_count" - case listedCount = "listed_count" - case createdAt = "created_at" - case favouritesCount = "favourites_count" - - case geoEnabled = "geo_enabled" - case verified = "verified" - case statusesCount = "statuses_count" - case contributorsEnabled = "contributors_enabled" - - case profileImageURLHTTPS = "profile_image_url_https" - case profileBannerURL = "profile_banner_url" - - case profileLinkColor = "profile_link_color" - case profileSidebarBorderColor = "profile_sidebar_border_color" - case profileSidebarFillColor = "profile_sidebar_fill_color" - case profileTextColor = "profile_text_color" - case hasExtendedProfile = "has_extended_profile" - case defaultProfile = "default_profile" - case defaultProfileImage = "default_profile_image" - case following = "following" - case followRequestSent = "follow_request_sent" - case notifications = "notifications" - } - - } -} - -extension Twitter.Entity.User: Equatable { } - - -extension Twitter.Entity.User { - public enum ProfileImageSize: String { - case original - case reasonablySmall = "reasonably_small" // 128 * 128 - case bigger // 73 * 73 - case normal // 48 * 48 - case mini // 24 * 24 - - static var suffixedSizes: [ProfileImageSize] { - return [.reasonablySmall, .bigger, .normal, .mini] - } - } - - /// https://developer.twitter.com/en/docs/twitter-api/v1/accounts-and-users/user-profile-images-and-banners - public func avatarImageURL(size: ProfileImageSize = .reasonablySmall) -> URL? { - guard let imageURLString = profileImageURLHTTPS, var imageURL = URL(string: imageURLString) else { return nil } - - let pathExtension = imageURL.pathExtension - imageURL.deletePathExtension() - - var imageIdentifier = imageURL.lastPathComponent - imageURL.deleteLastPathComponent() - for suffixedSize in Twitter.Entity.User.ProfileImageSize.suffixedSizes { - imageIdentifier.deleteSuffix("_\(suffixedSize.rawValue)") - } - - switch size { - case .original: - imageURL.appendPathComponent(imageIdentifier) - default: - imageURL.appendPathComponent(imageIdentifier + "_" + size.rawValue) - } - - imageURL.appendPathExtension(pathExtension) - - return imageURL - } -} - -extension String { - mutating func deleteSuffix(_ suffix: String) { - guard hasSuffix(suffix) else { return } - removeLast(suffix.count) - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity.swift b/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity.swift deleted file mode 100644 index 5a6370b5..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Entity/Twitter+Entity.swift +++ /dev/null @@ -1,10 +0,0 @@ -// -// Twitter+Entity.swift -// TwitterAPI -// -// Created by Cirno MainasuK on 2020-9-3. -// - -import Foundation - -extension Twitter.Entity { } diff --git a/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twiiter+Entity+V2+ReferencedTweet.swift b/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twiiter+Entity+V2+ReferencedTweet.swift deleted file mode 100644 index deff7ff7..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twiiter+Entity+V2+ReferencedTweet.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// Twiiter+Entity+V2+ReferencedTweet.swift -// -// -// Created by Cirno MainasuK on 2020-10-19. -// - -import Foundation - -extension Twitter.Entity.V2.Tweet { - public struct ReferencedTweet: Codable { - public let `type`: ReferencedType? - public let id: Twitter.Entity.V2.Tweet.ID? - - public enum CodingKeys: String, CodingKey { - case `type` = "type" - case id - } - } - -} - -extension Twitter.Entity.V2.Tweet.ReferencedTweet { - public enum ReferencedType: String, Codable { - case repliedTo = "replied_to" - case quoted - case retweeted - - public enum CodingKeys: String, CodingKey { - case repliedTo = "replied_to" - case quoted - case retweeted - } - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+Entities.swift b/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+Entities.swift deleted file mode 100644 index 93245a2f..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+Entities.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// Twitter+Entity+V2+Tweet+Entities.swift -// -// -// Created by Cirno MainasuK on 2020-10-19. -// - -import Foundation - -extension Twitter.Entity.V2 { - public class Entities: Codable { - // tweet - public let urls: [URL]? - public let hashtags: [Hashtag]? - public let mentions: [Mention]? - - // user - public let url: Entities? - public let description: Entities? - } -} - -extension Twitter.Entity.V2.Entities { - - public struct URL: Codable { - public let start: Int - public let end: Int - public let url: String - - // optional - public let expandedURL: String? - public let displayURL: String? - public let status: Int? - public let title: String? - public let description: String? - public let unwoundURL: String? - - public enum CodingKeys: String, CodingKey { - case start = "start" - case end = "end" - case url = "url" - case expandedURL = "expanded_url" - case displayURL = "display_url" - case status - case title - case description - case unwoundURL = "unwound_url" - } - } - - public struct Hashtag: Codable { - public let start: Int - public let end: Int - public let tag: String - - public enum CodingKeys: String, CodingKey { - case start = "start" - case end = "end" - case tag = "tag" - } - } - - public struct Mention: Codable { - public let start: Int - public let end: Int - public let username: String - public let id: Twitter.Entity.V2.User.ID? - } - -} diff --git a/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+List.swift b/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+List.swift deleted file mode 100644 index 935d3123..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+List.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// Twitter+Entity+V2+List.swift -// -// -// Created by MainasuK on 2022-2-28. -// - -import Foundation - -extension Twitter.Entity.V2 { - public struct List: Codable { - public typealias ID = String - - public let id: ID - public let name: String - - public let `private`: Bool? - public let memberCount: Int? - public let followerCount: Int? - public let description: String? - public let ownerID: Twitter.Entity.V2.User.ID? - public let createdAt: Date? - - public enum CodingKeys: String, CodingKey { - case id - case name - case `private` - case memberCount = "member_count" - case followerCount = "follower_count" - case description - case ownerID = "owner_id" - case createdAt = "created_at" - } - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+Media+PublicMetrics.swift b/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+Media+PublicMetrics.swift deleted file mode 100644 index f4a43f3b..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+Media+PublicMetrics.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// Twitter+Entity+V2+Media+PublicMetrics.swift -// -// -// Created by Cirno MainasuK on 2020/10/21. -// - -import Foundation - -extension Twitter.Entity.V2.Media { - public struct PublicMetrics: Codable { - public let viewCount: Int? - - public enum CodingKeys: String, CodingKey { - case viewCount = "view_count" - } - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+Media.swift b/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+Media.swift deleted file mode 100644 index e0ed442c..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+Media.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// Twitter+Entity+V2+Media.swift -// -// -// Created by Cirno MainasuK on 2020/10/21. -// - -import Foundation - -extension Twitter.Entity.V2 { - public struct Media: Codable, Identifiable { - public typealias ID = String - - public var id: ID { mediaKey } - - public let mediaKey: ID - public let type: String - - public let durationMS: Int? - public let width: Int? - public let height: Int? - public let url: String? - public let previewImageURL: String? - public let publicMetrics: PublicMetrics? - public let altText: String? - - enum CodingKeys: String, CodingKey { - case mediaKey = "media_key" - case type - - case durationMS = "duration_ms" - case width - case height - case url - case previewImageURL = "preview_image_url" - case publicMetrics = "public_metrics" - case altText = "alt_text" - } - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+Place.swift b/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+Place.swift deleted file mode 100644 index c49f81f4..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+Place.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// Twitter+Entity+V2+Place.swift -// -// -// Created by Cirno MainasuK on 2020-10-15. -// - -import Foundation - -extension Twitter.Entity.V2 { - public struct Place: Codable, Identifiable { - public typealias ID = String - - public let id: ID - public let fullName: String - - public let country: String? - public let countryCode: String? - public let name: String? - public let placeType: String? - - enum CodingKeys: String, CodingKey { - case id = "id" - case fullName = "full_name" - - case country = "country" - case countryCode = "country_code" - case name = "name" - case placeType = "place_type" - } - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+Tweet+Attachments.swift b/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+Tweet+Attachments.swift deleted file mode 100644 index b39e8cdb..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+Tweet+Attachments.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// File.swift -// -// -// Created by Cirno MainasuK on 2020/10/21. -// - -import Foundation - -extension Twitter.Entity.V2.Tweet { - public struct Attachments: Codable { - public let mediaKeys: [Twitter.Entity.V2.Media.ID]? - public let pollIDs: [Twitter.Entity.V2.Tweet.Poll.ID]? - - public enum CodingKeys: String, CodingKey { - case mediaKeys = "media_keys" - case pollIDs = "poll_ids" - } - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+Tweet+Geo.swift b/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+Tweet+Geo.swift deleted file mode 100644 index d28a3635..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+Tweet+Geo.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// Twitter+Entity+V2+Tweet+Geo.swift -// -// -// Created by Cirno MainasuK on 2020-10-19. -// - -import Foundation - -extension Twitter.Entity.V2.Tweet { - public struct Geo: Codable { - public let placeID: Twitter.Entity.V2.Place.ID? - public let coordinates: Coordinate? - - public init( - placeID: Twitter.Entity.V2.Place.ID?, - coordinates: Twitter.Entity.V2.Tweet.Geo.Coordinate? = nil - ) { - self.placeID = placeID - self.coordinates = coordinates - } - - public enum CodingKeys: String, CodingKey { - case placeID = "place_id" - case coordinates - } - } -} - -extension Twitter.Entity.V2.Tweet.Geo { - public struct Coordinate: Codable { - public let type: String - public let coordinates: [Double] - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+Tweet+Poll.swift b/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+Tweet+Poll.swift deleted file mode 100644 index 0890b874..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+Tweet+Poll.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// Twitter+Entity+V2+Tweet+Poll.swift -// -// -// Created by MainasuK on 2022-6-6. -// - -import Foundation - -extension Twitter.Entity.V2.Tweet { - public struct Poll: Codable, Identifiable { - public typealias ID = String - - public let id: ID - public let options: [Option] - - public let votingStatus: VotingStatus - public let durationMinutes: Int? - public let endDatetime: Date? - - public enum CodingKeys: String, CodingKey { - case id - case options - case votingStatus = "voting_status" - case durationMinutes = "duration_minutes" - case endDatetime = "end_datetime" - } - } -} - -extension Twitter.Entity.V2.Tweet.Poll { - public enum VotingStatus: String, Codable, CaseIterable { - case open - case closed - } - - public struct Option: Codable { - public let position: Int - public let label: String - public let votes: Int - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+Tweet+PublicMetrics.swift b/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+Tweet+PublicMetrics.swift deleted file mode 100644 index 7d280e0c..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+Tweet+PublicMetrics.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// Twitter+Entity+V2+Tweet+PublicMetrics.swift -// -// -// Created by Cirno MainasuK on 2020-10-19. -// - -import Foundation - -extension Twitter.Entity.V2.Tweet { - public struct PublicMetrics: Codable { - public let retweetCount: Int - public let replyCount: Int - public let likeCount: Int - public let quoteCount: Int - - public enum CodingKeys: String, CodingKey { - case retweetCount = "retweet_count" - case replyCount = "reply_count" - case likeCount = "like_count" - case quoteCount = "quote_count" - } - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+Tweet+ReplySettings.swift b/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+Tweet+ReplySettings.swift deleted file mode 100644 index 09059252..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+Tweet+ReplySettings.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// Twitter+Entity+V2+Tweet+ReplySettings.swift -// -// -// Created by MainasuK on 2022-5-24. -// - -import Foundation - -extension Twitter.Entity.V2.Tweet { - public enum ReplySettings: String, Codable, Hashable, CaseIterable { - case everyone - case following - case mentionedUsers = "mentionedUsers" // value not has underscore?! - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+Tweet.swift b/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+Tweet.swift deleted file mode 100644 index c2b2819e..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+Tweet.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// Twitter+Entity+V2+Tweet.swift -// TwitterAPI -// -// Created by Cirno MainasuK on 2020-9-15. -// - -import Foundation - -extension Twitter.Entity.V2 { - public struct Tweet: Codable, Identifiable { - - public typealias ID = String - public typealias ConversationID = String - - // Fundamental - public let id: ID - public let text: String - - // Extra - public let attachments: Attachments? - public let authorID: String? - // public let contextAnnotations - public let conversationID: ConversationID? - public let createdAt: Date // client required - public let entities: Entities? - public let geo: Geo? - public let inReplyToUserID: User.ID? - public let lang: String? - public let publicMetrics: PublicMetrics? - public let possiblySensitive: Bool? - public let referencedTweets: [ReferencedTweet]? - public let replySettings: ReplySettings? - public let source: String? - public let withheld: Withheld? - - public enum CodingKeys: String, CodingKey { - case id - case text - - case attachments - case authorID = "author_id" - //case context_annotations - case conversationID = "conversation_id" - case createdAt = "created_at" - case entities - case inReplyToUserID = "in_reply_to_user_id" - case geo - case lang - case publicMetrics = "public_metrics" - case possiblySensitive = "possibly_sensitive" - case referencedTweets = "referenced_tweets" - case replySettings = "reply_settings" - case source - case withheld - } - - } -} - - diff --git a/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+User+PublicMetrics.swift b/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+User+PublicMetrics.swift deleted file mode 100644 index 0d651417..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+User+PublicMetrics.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// Twitter+Entity+V2+User+PublicMetrics.swift -// -// -// Created by Cirno MainasuK on 2020-10-20. -// - -import Foundation - -extension Twitter.Entity.V2.User { - public struct PublicMetrics: Codable { - public let followersCount: Int - public let followingCount: Int - public let tweetCount: Int - public let listedCount: Int - - public enum CodingKeys: String, CodingKey { - case followersCount = "followers_count" - case followingCount = "following_count" - case tweetCount = "tweet_count" - case listedCount = "listed_count" - } - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+User.swift b/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+User.swift deleted file mode 100644 index 7d423843..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+User.swift +++ /dev/null @@ -1,89 +0,0 @@ -// -// Twitter+Entity+V2+User.swift -// -// -// Created by Cirno MainasuK on 2020-10-16. -// - -import Foundation - -extension Twitter.Entity.V2 { - public struct User: Codable, Identifiable { - public typealias ID = String - - // Fundamental - public let id: ID - public let name: String - public let username: String - - // Extra - public let createdAt: Date? - public let description: String? - public let entities: Entities? - public let location: String? - public let pinnedTweetID: Tweet.ID? - public let profileImageURL: String? - public let protected: Bool? - public let publicMetrics: PublicMetrics? - public let url: String? - public let verified: Bool? - public let withheld: Withheld? - - public enum CodingKeys: String, CodingKey { - case id - case name - case username - - case createdAt = "created_at" - case description = "description" - case entities = "entities" - case location = "location" - case pinnedTweetID = "pinned_tweet_id" - case profileImageURL = "profile_image_url" - case protected = "protected" - case publicMetrics = "public_metrics" - case url = "url" - case verified = "verified" - case withheld = "withheld" - } - } -} - -extension Twitter.Entity.V2.User { - public enum ProfileImageSize: String { - case original - case reasonablySmall = "reasonably_small" // 128 * 128 - case bigger // 73 * 73 - case normal // 48 * 48 - case mini // 24 * 24 - - static var suffixedSizes: [ProfileImageSize] { - return [.reasonablySmall, .bigger, .normal, .mini] - } - } - - /// https://developer.twitter.com/en/docs/twitter-api/v1/accounts-and-users/user-profile-images-and-banners - public func avatarImageURL(size: ProfileImageSize = .reasonablySmall) -> URL? { - guard let imageURLString = profileImageURL, var imageURL = URL(string: imageURLString) else { return nil } - - let pathExtension = imageURL.pathExtension - imageURL.deletePathExtension() - - var imageIdentifier = imageURL.lastPathComponent - imageURL.deleteLastPathComponent() - for suffixedSize in Twitter.Entity.User.ProfileImageSize.suffixedSizes { - imageIdentifier.deleteSuffix("_\(suffixedSize.rawValue)") - } - - switch size { - case .original: - imageURL.appendPathComponent(imageIdentifier) - default: - imageURL.appendPathComponent(imageIdentifier + "_" + size.rawValue) - } - - imageURL.appendPathExtension(pathExtension) - - return imageURL - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+Withheld.swift b/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+Withheld.swift deleted file mode 100644 index ffd90790..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Entity/V2/Twitter+Entity+V2+Withheld.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// Twitter+Entity+V2+Tweet+Withheld.swift -// -// -// Created by Cirno MainasuK on 2020-10-19. -// - -import Foundation - -extension Twitter.Entity.V2 { - public struct Withheld: Codable, Hashable { - public let copyright: Bool? - public let countryCodes: [String]? - - public enum CodingKeys: String, CodingKey { - case copyright - case countryCodes = "country_codes" - } - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/Helper.swift b/TwidereSDK/Sources/TwitterSDK/Helper.swift deleted file mode 100644 index 0bf9991f..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Helper.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// Helper.swift -// -// -// Created by Cirno MainasuK on 2020-10-16. -// - -import Foundation - -// MARK: - Helper - -extension String { - - var urlEncoded: String { - let customAllowedSet = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~") - return self.addingPercentEncoding(withAllowedCharacters: customAllowedSet)! - } - -} - -extension Dictionary { - - var queryString: String { - var parts = [String]() - - for (key, value) in self { - let query: String = "\(key)=\(value)" - parts.append(query) - } - - return parts.joined(separator: "&") - } - - var urlEncodedQuery: String { - var parts = [String]() - - for (key, value) in self { - let keyString = "\(key)".urlEncoded - let valueString = "\(value)".urlEncoded - let query = "\(keyString)=\(valueString)" - parts.append(query) - } - - return parts.joined(separator: "&") - } - -} diff --git a/TwidereSDK/Sources/TwitterSDK/Request/Query.swift b/TwidereSDK/Sources/TwitterSDK/Request/Query.swift deleted file mode 100644 index d59b19a3..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Request/Query.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// Query.swift -// Query -// -// Created by Cirno MainasuK on 2021-8-19. -// - -import Foundation - -protocol Query { - var queryItems: [URLQueryItem]? { get } - var encodedQueryItems: [URLQueryItem]? { get } - var formQueryItems: [URLQueryItem]? { get } - var contentType: String? { get } - var body: Data? { get } -} - -protocol JSONEncodeQuery: Query, Encodable { } - -extension Query where Self: JSONEncodeQuery { - var contentType: String? { - return "application/json; charset=utf-8" - } - - var body: Data? { - return try? JSONEncoder().encode(self) - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/Request/Twitter+Request+Expansions.swift b/TwidereSDK/Sources/TwitterSDK/Request/Twitter+Request+Expansions.swift deleted file mode 100644 index 91abc8e7..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Request/Twitter+Request+Expansions.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// Twitter+Request+Expansions.swift -// TwitterAPI -// -// Created by Cirno MainasuK on 2020-9-16. -// - -import Foundation - -extension Twitter.Request { - public enum Expansions: String { - case attachmentsPollIDs = "attachments.poll_ids" - case attachmentsMediaKeys = "attachments.media_keys" - case authorID = "author_id" - case entitiesMentionsUsername = "entities.mentions.username" - case geoPlaceID = "geo.place_id" - case inReplyToUserID = "in_reply_to_user_id" - case referencedTweetsID = "referenced_tweets.id" - case referencedTweetsIDAuthorID = "referenced_tweets.id.author_id" - case pinnedTweetID = "pinned_tweet_id" - case ownerID = "owner_id" - } -} - -extension Collection where Element == Twitter.Request.Expansions { - public var queryItem: URLQueryItem { - let value = self.map { $0.rawValue }.joined(separator: ",") - return URLQueryItem(name: "expansions", value: value) - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/Request/Twitter+Request+ListFields.swift b/TwidereSDK/Sources/TwitterSDK/Request/Twitter+Request+ListFields.swift deleted file mode 100644 index 13c94fe6..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Request/Twitter+Request+ListFields.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// Twitter+Request+ListFields.swift -// -// -// Created by MainasuK on 2022-2-28. -// - -import Foundation - -extension Twitter.Request { - public enum ListFields: String, CaseIterable { - case createdAt = "created_at" - case followerCount = "follower_count" - case memberCount = "member_count" - case `private` = "private" - case description = "description" - case ownerID = "owner_id" - } -} - -extension Collection where Element == Twitter.Request.ListFields { - public var queryItem: URLQueryItem { - let value = self.map { $0.rawValue }.joined(separator: ",") - return URLQueryItem(name: "list.fields", value: value) - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/Request/Twitter+Request+MediaFields.swift b/TwidereSDK/Sources/TwitterSDK/Request/Twitter+Request+MediaFields.swift deleted file mode 100644 index 92c66854..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Request/Twitter+Request+MediaFields.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// Twitter+Request+MediaFields.swift -// TwitterAPI -// -// Created by Cirno MainasuK on 2020-9-16. -// - -import Foundation - -extension Twitter.Request { - public enum MediaFields: String, CaseIterable { - case durationMS = "duration_ms" - case height = "height" - case mediaKey = "media_key" - case previewImageURL = "preview_image_url" - case type = "type" - case url = "url" - case width = "width" - case publicMetrics = "public_metrics" - case nonPublicMetrics = "non_public_metrics" - case organicMetrics = "organic_metrics" - case promotedMetrics = "promoted_metrics" - case altText = "alt_text" - } -} - -extension Collection where Element == Twitter.Request.MediaFields { - public var queryItem: URLQueryItem { - let value = self.map { $0.rawValue }.joined(separator: ",") - return URLQueryItem(name: "media.fields", value: value) - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/Request/Twitter+Request+PlaceFields.swift b/TwidereSDK/Sources/TwitterSDK/Request/Twitter+Request+PlaceFields.swift deleted file mode 100644 index 61f547a2..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Request/Twitter+Request+PlaceFields.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// Twitter+Request+PlaceFields.swift -// TwitterAPI -// -// Created by Cirno MainasuK on 2020-9-16. -// - -import Foundation - -extension Twitter.Request { - public enum PlaceFields: String, CaseIterable { - case containedWithin = "contained_within" - case country = "country" - case countryCode = "country_code" - case fullName = "full_name" - case geo = "geo" - case id = "id" - case name = "name" - case placeType = "place_type" - } -} - -extension Collection where Element == Twitter.Request.PlaceFields { - public var queryItem: URLQueryItem { - let value = self.map { $0.rawValue }.joined(separator: ",") - return URLQueryItem(name: "place.fields", value: value) - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/Request/Twitter+Request+PollFields.swift b/TwidereSDK/Sources/TwitterSDK/Request/Twitter+Request+PollFields.swift deleted file mode 100644 index db38f062..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Request/Twitter+Request+PollFields.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// Twitter+Request+PollFields.swift -// TwitterAPI -// -// Created by Cirno MainasuK on 2020-9-16. -// - -import Foundation - -extension Twitter.Request { - public enum PollFields: String, CaseIterable { - case durationMinutes = "duration_minutes" - case endDatetime = "end_datetime" - case id = "id" - case options = "options" - case votingStatus = "voting_status" - } -} - -extension Collection where Element == Twitter.Request.PollFields { - public var queryItem: URLQueryItem { - let value = self.map { $0.rawValue }.joined(separator: ",") - return URLQueryItem(name: "poll.fields", value: value) - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/Request/Twitter+Request+TweetFields.swift b/TwidereSDK/Sources/TwitterSDK/Request/Twitter+Request+TweetFields.swift deleted file mode 100644 index 526c17d0..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Request/Twitter+Request+TweetFields.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// Twitter+Request+TweetFields.swift -// TwitterAPI -// -// Created by Cirno MainasuK on 2020-9-15. -// - -import Foundation - -extension Twitter.Request { - public enum TwitterFields: String, CaseIterable { - case attachments = "attachments" - case authorID = "author_id" - case contextAnnotations = "context_annotations" - case conversationID = "conversation_id" - case created_at = "created_at" - case entities = "entities" - case geo = "geo" - case id = "id" - case inReplyToUserID = "in_reply_to_user_id" - case lang = "lang" - case nonPublicMetrics = "non_public_metrics" - case publicMetrics = "public_metrics" - case organicMetrics = "organic_metrics" - case promotedMetrics = "promoted_metrics" - case possiblySensitive = "possibly_sensitive" - case referencedTweets = "referenced_tweets" - case replySettings = "reply_settings" - case source = "source" - case text = "text" - case withheld = "withheld" - } -} - -extension Collection where Element == Twitter.Request.TwitterFields { - public var queryItem: URLQueryItem { - let value = self.map { $0.rawValue }.joined(separator: ",") - return URLQueryItem(name: "tweet.fields", value: value) - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/Request/Twitter+Request+UserFields.swift b/TwidereSDK/Sources/TwitterSDK/Request/Twitter+Request+UserFields.swift deleted file mode 100644 index 2bb1adc8..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Request/Twitter+Request+UserFields.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// Twitter+Request+UserFields.swift -// TwitterAPI -// -// Created by Cirno MainasuK on 2020-9-16. -// - -import Foundation - -extension Twitter.Request { - public enum UserFields: String, CaseIterable { - case createdAt = "created_at" - case description = "description" - case entities = "entities" - case id = "id" - case location = "location" - case name = "name" - case pinnedTweetID = "pinned_tweet_id" - case profileImageURL = "profile_image_url" - case protected = "protected" - case publicMetrics = "public_metrics" - case url = "url" - case username = "username" - case verified = "verified" - case withheld = "withheld" - } -} - -extension Collection where Element == Twitter.Request.UserFields { - public var queryItem: URLQueryItem { - let value = self.map { $0.rawValue }.joined(separator: ",") - return URLQueryItem(name: "user.fields", value: value) - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/Request/Twitter+Request.swift b/TwidereSDK/Sources/TwitterSDK/Request/Twitter+Request.swift deleted file mode 100644 index 7075ac53..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Request/Twitter+Request.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// Twitter+Request.swift -// TwitterAPI -// -// Created by Cirno MainasuK on 2020-9-15. -// - -import Foundation - -extension Twitter.Request { - -} - -// TODO: unit tests -extension Twitter.Request { - static let expansions: [Twitter.Request.Expansions] = [ - .attachmentsPollIDs, - .attachmentsMediaKeys, - .authorID, - .entitiesMentionsUsername, - .geoPlaceID, - .inReplyToUserID, - .referencedTweetsID, - .referencedTweetsIDAuthorID - ] - static let tweetsFields: [Twitter.Request.TwitterFields] = [ - .attachments, - .authorID, - .contextAnnotations, - .conversationID, - .created_at, - .entities, - .geo, - .id, - .inReplyToUserID, - .lang, - .publicMetrics, - .possiblySensitive, - .referencedTweets, - .replySettings, - .source, - .text, - .withheld, - ] - static let userFields: [Twitter.Request.UserFields] = [ - .createdAt, - .description, - .entities, - .id, - .location, - .name, - .pinnedTweetID, - .profileImageURL, - .protected, - .publicMetrics, - .url, - .username, - .verified, - .withheld - ] - static let mediaFields: [Twitter.Request.MediaFields] = [ - .durationMS, - .height, - .mediaKey, - .previewImageURL, - .type, - .url, - .width, - .publicMetrics, - .altText - ] - static let placeFields: [Twitter.Request.PlaceFields] = [ - .containedWithin, - .country, - .countryCode, - .fullName, - .geo, - .id, - .name, - .placeType, - ] - static let pollFields: [Twitter.Request.PollFields] = [ - .durationMinutes, - .endDatetime, - .id, - .options, - .votingStatus, - ] - static let listFields: [Twitter.Request.ListFields] = [ - .createdAt, - .followerCount, - .memberCount, - .private, - .description, - .ownerID, - ] -} diff --git a/TwidereSDK/Sources/TwitterSDK/Response/Twitter+Response.swift b/TwidereSDK/Sources/TwitterSDK/Response/Twitter+Response.swift deleted file mode 100644 index e041a902..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Response/Twitter+Response.swift +++ /dev/null @@ -1,89 +0,0 @@ -// -// Twitter+Response.swift -// -// -// Created by Cirno MainasuK on 2020-10-19. -// - -import Foundation - -extension Twitter.Response { - public struct Content { - // entity - public let value: T - - // standard fields - public let date: Date? - - // application fields - public let rateLimit: Twitter.Response.RateLimit? - public let responseTime: Int? - - public var networkDate: Date { - return date ?? Date() - } - - public init(value: T, response: URLResponse) { - self.value = value - - self.date = { - guard let string = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "date") else { return nil } - return Twitter.API.httpHeaderDateFormatter.date(from: string) - }() - - self.rateLimit = RateLimit(response: response) - self.responseTime = { - guard let string = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "x-response-time") else { return nil } - return Int(string) - }() - } - - init(value: T, old: Twitter.Response.Content) { - self.value = value - self.date = old.date - self.rateLimit = old.rateLimit - self.responseTime = old.responseTime - } - } -} - -extension Twitter.Response.Content { - public func map(_ transform: (T) -> R) -> Twitter.Response.Content { - return Twitter.Response.Content(value: transform(value), old: self) - } -} - -extension Twitter.Response { - public struct RateLimit { - public let limit: Int - public let remaining: Int - public let reset: Date - - public init(limit: Int, remaining: Int, reset: Date) { - self.limit = limit - self.remaining = remaining - self.reset = reset - } - - public init?(response: URLResponse) { - guard let response = response as? HTTPURLResponse else { - return nil - } - - guard let limitString = response.value(forHTTPHeaderField: "x-rate-limit-limit"), - let limit = Int(limitString), - let remainingString = response.value(forHTTPHeaderField: "x-rate-limit-remaining"), - let remaining = Int(remainingString) else { - return nil - } - - guard let resetTimestampString = response.value(forHTTPHeaderField: "x-rate-limit-reset"), - let resetTimestamp = Int(resetTimestampString) else { - return nil - } - let reset = Date(timeIntervalSince1970: Double(resetTimestamp)) - - self.init(limit: limit, remaining: remaining, reset: reset) - } - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/Response/V2/Twitter+Response+V2+ContentError.swift b/TwidereSDK/Sources/TwitterSDK/Response/V2/Twitter+Response+V2+ContentError.swift deleted file mode 100644 index 3ace1ce5..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Response/V2/Twitter+Response+V2+ContentError.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// Twitter+Response+V2+ContentError.swift -// -// -// Created by Cirno MainasuK on 2020-12-22. -// - -import Foundation - -extension Twitter.Response.V2 { - public struct ContentError: Codable { - public let detail: String - public let title: String - public let resourceType: String - public let parameter: String - public let value: String - public let type: String - - public enum CodingKeys: String, CodingKey { - case detail - case title - case resourceType = "resource_type" - case parameter - case value - case type - } - } -} diff --git a/TwidereSDK/Sources/TwitterSDK/Response/V2/Twitter+Response+V2+DictContent.swift b/TwidereSDK/Sources/TwitterSDK/Response/V2/Twitter+Response+V2+DictContent.swift deleted file mode 100644 index e59de593..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Response/V2/Twitter+Response+V2+DictContent.swift +++ /dev/null @@ -1,91 +0,0 @@ -// -// Twitter+Response+V2+DictContent.swift -// -// -// Created by Cirno MainasuK on 2020-10-19. -// - -import Foundation -import OrderedCollections - -extension Twitter.Response.V2 { - - public class DictContent { - public let tweetDict: OrderedDictionary - public let userDict: OrderedDictionary - public let mediaDict: OrderedDictionary - public let placeDict: OrderedDictionary - public let pollDict: OrderedDictionary - - public init( - tweetDict: OrderedDictionary, - userDict: OrderedDictionary, - mediaDict: OrderedDictionary, - placeDict: OrderedDictionary, - pollDict: OrderedDictionary - ) { - self.tweetDict = tweetDict - self.userDict = userDict - self.mediaDict = mediaDict - self.placeDict = placeDict - self.pollDict = pollDict - } - - public convenience init( - tweets: [Twitter.Entity.V2.Tweet], - users: [Twitter.Entity.V2.User], - media: [Twitter.Entity.V2.Media], - places: [Twitter.Entity.V2.Place], - polls: [Twitter.Entity.V2.Tweet.Poll] - ) { - self.init( - tweetDict: Twitter.Response.V2.DictContent.collect(array: tweets), - userDict: Twitter.Response.V2.DictContent.collect(array: users), - mediaDict: Twitter.Response.V2.DictContent.collect(array: media), - placeDict: Twitter.Response.V2.DictContent.collect(array: places), - pollDict: Twitter.Response.V2.DictContent.collect(array: polls) - ) - } - } - -} - -extension Twitter.Response.V2.DictContent { - - private static func collect(array: [T]) -> OrderedDictionary { - var dict: OrderedDictionary = [:] - for element in array { - guard dict[element.id] == nil else { - continue - } - dict[element.id] = element - } - return dict - } - -} - -extension Twitter.Response.V2.DictContent { - - public func media(for tweet: Twitter.Entity.V2.Tweet) -> [Twitter.Entity.V2.Media]? { - guard let mediaKeys = tweet.attachments?.mediaKeys else { return nil } - var array: [Twitter.Entity.V2.Media] = [] - for mediaKey in mediaKeys { - guard let media = mediaDict[mediaKey] else { continue } - array.append(media) - } - guard !array.isEmpty else { return nil } - return array - } - - public func place(for tweet: Twitter.Entity.V2.Tweet) -> Twitter.Entity.V2.Place? { - guard let placeID = tweet.geo?.placeID else { return nil } - return placeDict[placeID] - } - - public func poll(for tweet: Twitter.Entity.V2.Tweet) -> Twitter.Entity.V2.Tweet.Poll? { - guard let pollID = tweet.attachments?.pollIDs?.first else { return nil } - return pollDict[pollID] - } - -} diff --git a/TwidereSDK/Sources/TwitterSDK/Twitter.swift b/TwidereSDK/Sources/TwitterSDK/Twitter.swift deleted file mode 100644 index 8a2811af..00000000 --- a/TwidereSDK/Sources/TwitterSDK/Twitter.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// Twitter.swift -// TwitterAPI -// -// Created by Cirno MainasuK on 2020-9-1. -// - -import Foundation - -public enum Twitter { - public enum Request { } - public enum Response { - public enum V2 { } - } - public enum API { } - public enum Entity { - public enum V2 { } - } -} diff --git a/TwidereSDK/Sources/TwitterSDKTests/TwitterSDKTests.swift b/TwidereSDK/Sources/TwitterSDKTests/TwitterSDKTests.swift deleted file mode 100644 index 71bc10da..00000000 --- a/TwidereSDK/Sources/TwitterSDKTests/TwitterSDKTests.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// TwitterSDKTests.swift -// TwitterSDKTests -// -// Created by Cirno MainasuK on 2021-8-12. -// - -import os.log -import XCTest -@testable import TwitterSDK -import CommonOSLog - -final class TwitterSDKTests: XCTestCase { - - let logger = Logger() - - func testSmoke() throws { } -} - -// Note: -// only for unit test! -// https://gist.github.com/shobotch/5160017 -extension TwitterSDKTests { - - var consumerKey: String { "3rJOl1ODzm9yZy63FACdg" } - var consumerSecret: String { "5jPoQ5kQvMJFDYRNE8bQ4rHuds4xJqhvgNJM4awaE8" } - - func testOAuthRequestToken() async throws { -// let query = Twitter.API.OAuth.RequestTokenQuery(consumerKey: consumerKey, consumerSecret: consumerSecret) -// let response = try await Twitter.API.OAuth.requestToken(session: URLSession.shared, query: query) -// logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): response: \n\(response.debugDescription)") - } -} diff --git a/TwidereX.xcodeproj/project.pbxproj b/TwidereX.xcodeproj/project.pbxproj index cdfe207a..e95282c9 100644 --- a/TwidereX.xcodeproj/project.pbxproj +++ b/TwidereX.xcodeproj/project.pbxproj @@ -21,10 +21,14 @@ DB01A9A427637FE60055FABC /* DataSourceFacade+Meta.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB01A9A327637FE60055FABC /* DataSourceFacade+Meta.swift */; }; DB02C76D27350D71007EA0BF /* SearchHashtagViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02C76C27350D71007EA0BF /* SearchHashtagViewController.swift */; }; DB02C77027350D8A007EA0BF /* SearchHashtagViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02C76F27350D8A007EA0BF /* SearchHashtagViewModel.swift */; }; - DB02C77227351B7D007EA0BF /* HashtagTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02C77127351B7D007EA0BF /* HashtagTableViewCell.swift */; }; - DB02C77527351DA2007EA0BF /* HashtagTableViewCell+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02C77427351DA2007EA0BF /* HashtagTableViewCell+ViewModel.swift */; }; DB02C777273520B8007EA0BF /* SearchHashtagViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02C776273520B8007EA0BF /* SearchHashtagViewModel+State.swift */; }; DB02C77927352217007EA0BF /* SearchHashtagViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02C77827352217007EA0BF /* SearchHashtagViewModel+Diffable.swift */; }; + DB0455B62A1CA2F3009A00EF /* NewColumnViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0455B52A1CA2F3009A00EF /* NewColumnViewModel.swift */; }; + DB0455B82A1CA510009A00EF /* NewColumnView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0455B72A1CA510009A00EF /* NewColumnView.swift */; }; + DB05E13729C318530055BF3F /* TwidereSDK in Frameworks */ = {isa = PBXBuildFile; productRef = DB05E13629C318530055BF3F /* TwidereSDK */; }; + DB05E13929C318590055BF3F /* TwidereSDK in Frameworks */ = {isa = PBXBuildFile; productRef = DB05E13829C318590055BF3F /* TwidereSDK */; }; + DB05E13B29C3185E0055BF3F /* TwidereSDK in Frameworks */ = {isa = PBXBuildFile; productRef = DB05E13A29C3185E0055BF3F /* TwidereSDK */; }; + DB05E13D29C321960055BF3F /* TwidereSDK in Frameworks */ = {isa = PBXBuildFile; productRef = DB05E13C29C321960055BF3F /* TwidereSDK */; }; DB0618102786EC870030EE79 /* LineChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB06180F2786EC870030EE79 /* LineChartView.swift */; }; DB0AD4DF285872BE0002ABDB /* UserMediaTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0AD4DE285872BE0002ABDB /* UserMediaTimelineViewController.swift */; }; DB0AD4E22858734A0002ABDB /* UserMediaTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0AD4E12858734A0002ABDB /* UserMediaTimelineViewModel.swift */; }; @@ -39,12 +43,16 @@ DB148B03281A7AB300B596C7 /* ContentSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB148B02281A7AB300B596C7 /* ContentSplitViewController.swift */; }; DB148B05281A81AE00B596C7 /* SidebarViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB148B04281A81AE00B596C7 /* SidebarViewModel.swift */; }; DB148B0C281A837E00B596C7 /* SidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB148B0B281A837E00B596C7 /* SidebarView.swift */; }; + DB1B80462A403B0A00C90A7D /* UserItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1B80442A403B0A00C90A7D /* UserItem.swift */; }; + DB1B80472A403B0A00C90A7D /* UserSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1B80452A403B0A00C90A7D /* UserSection.swift */; }; + DB1D3DEA289388CD008F0BD0 /* HistoryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1D3DE9289388CD008F0BD0 /* HistoryViewController.swift */; }; + DB1D3DED28938ACD008F0BD0 /* HistoryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1D3DEC28938ACD008F0BD0 /* HistoryViewModel.swift */; }; + DB1D3DEF28938CD1008F0BD0 /* StatusHistoryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1D3DEE28938CD1008F0BD0 /* StatusHistoryViewController.swift */; }; + DB1D3DF228938CDF008F0BD0 /* StatusHistoryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1D3DF128938CDF008F0BD0 /* StatusHistoryViewModel.swift */; }; DB1D7B4325B5938400397DCD /* TwitterAuthenticationOptionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1D7B4225B5938300397DCD /* TwitterAuthenticationOptionViewController.swift */; }; DB1D7B5525B5A87200397DCD /* TwitterAuthenticationOptionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1D7B5425B5A87200397DCD /* TwitterAuthenticationOptionViewModel.swift */; }; DB1E48122772CE380074F6A0 /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB36F375257F79DB0028F81E /* SearchViewModel.swift */; }; DB1E48142772CE850074F6A0 /* SearchViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1E48132772CE850074F6A0 /* SearchViewModel+Diffable.swift */; }; - DB1E48162772CEC20074F6A0 /* SearchSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1E48152772CEC20074F6A0 /* SearchSection.swift */; }; - DB1E48192772CECC0074F6A0 /* SearchItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1E48182772CECC0074F6A0 /* SearchItem.swift */; }; DB235EF42834DD0900398FCA /* SettingListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB235EF32834DD0900398FCA /* SettingListViewModel.swift */; }; DB235EF62834DDD200398FCA /* AboutViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB235EF52834DDD200398FCA /* AboutViewModel.swift */; }; DB25C4C527798E1A00EC1435 /* DataSourceFacade+SavedSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB25C4C427798E1A00EC1435 /* DataSourceFacade+SavedSearch.swift */; }; @@ -54,7 +62,6 @@ DB25C4CF2779A06D00EC1435 /* SavedSearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB25C4CE2779A06D00EC1435 /* SavedSearchViewModel.swift */; }; DB25C4D12779A37E00EC1435 /* SavedSearchViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB25C4D02779A37E00EC1435 /* SavedSearchViewModel+Diffable.swift */; }; DB25C4D32779ADD800EC1435 /* SearchResultContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB25C4D22779ADD800EC1435 /* SearchResultContainerViewController.swift */; }; - DB2611B7251B2D42004BF309 /* CryptoSwift in Frameworks */ = {isa = PBXBuildFile; productRef = DB2611B6251B2D42004BF309 /* CryptoSwift */; }; DB262A332721377800D18EF3 /* DataSourceFacade+Block.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB262A322721377800D18EF3 /* DataSourceFacade+Block.swift */; }; DB262A3727213FBE00D18EF3 /* UIAlertAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB262A3627213FBE00D18EF3 /* UIAlertAction.swift */; }; DB262A392721621900D18EF3 /* DataSourceFacade+User.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB262A382721621800D18EF3 /* DataSourceFacade+User.swift */; }; @@ -77,14 +84,11 @@ DB2D36F927D5E74D00C1FBE0 /* CompositeListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2D36F827D5E74D00C1FBE0 /* CompositeListViewModel.swift */; }; DB2EBBF0255D368200956CAA /* TableViewEntryRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2EBBEF255D368200956CAA /* TableViewEntryRow.swift */; }; DB2FFF3E258B78B0003DBC19 /* AVPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2FFF3D258B78B0003DBC19 /* AVPlayer.swift */; }; - DB30ADDC26CFC7EE00B2D2BE /* StatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB30ADDB26CFC7EE00B2D2BE /* StatusTableViewCell.swift */; }; DB30ADDD26CFD3CC00B2D2BE /* HomeTimelineViewController+DebugAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB2513D2599DBAB0064A876 /* HomeTimelineViewController+DebugAction.swift */; }; DB33A4A925A319A0003CED7D /* ActionToolbarContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB33A4A825A319A0003CED7D /* ActionToolbarContainer.swift */; }; - DB34029D2521BE8B009EFADF /* MeProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB34029C2521BE8B009EFADF /* MeProfileViewModel.swift */; }; DB36F35E257F74C10028F81E /* ScrollViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB36F35D257F74C00028F81E /* ScrollViewContainer.swift */; }; DB37F6A0274B556B0081603F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB37F69F274B556B0081603F /* Assets.xcassets */; }; DB3B905F26E8A9650010F64C /* StatusThreadViewController+DataSourceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3B905E26E8A9650010F64C /* StatusThreadViewController+DataSourceProvider.swift */; }; - DB3B906126E8AB480010F64C /* StatusViewTableViewCellDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3B906026E8AB480010F64C /* StatusViewTableViewCellDelegate.swift */; }; DB3B906326E8BBD70010F64C /* ProfileHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3B906226E8BBD70010F64C /* ProfileHeaderView.swift */; }; DB3B906526E8BF1F0010F64C /* ProfileHeaderView+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3B906426E8BF1F0010F64C /* ProfileHeaderView+ViewModel.swift */; }; DB3B906726E8CD6D0010F64C /* ProfileHeaderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3B906626E8CD6D0010F64C /* ProfileHeaderViewModel.swift */; }; @@ -117,9 +121,6 @@ DB47AB1A27CCB7EC00CD73C7 /* ListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB47AB1927CCB7EC00CD73C7 /* ListViewController.swift */; }; DB47AB1D27CCB88000CD73C7 /* ListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB47AB1C27CCB88000CD73C7 /* ListViewModel.swift */; }; DB47AB2D27CE085900CD73C7 /* ListViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB47AB2C27CE085800CD73C7 /* ListViewModel+Diffable.swift */; }; - DB47AB2E27CE097C00CD73C7 /* ListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB47AB1F27CCC18500CD73C7 /* ListItem.swift */; }; - DB47AB2F27CE097C00CD73C7 /* ListSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB47AB2027CCC18500CD73C7 /* ListSection.swift */; }; - DB47AB3E27CE1F9E00CD73C7 /* TimelineTopLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB47AB3D27CE1F9E00CD73C7 /* TimelineTopLoaderTableViewCell.swift */; }; DB51DC1A2715581E00A0D8FB /* ProfileDashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB51DC192715581E00A0D8FB /* ProfileDashboardView.swift */; }; DB51DC1C2715588E00A0D8FB /* ProfileDashboardMeterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB51DC1B2715588E00A0D8FB /* ProfileDashboardMeterView.swift */; }; DB51DC3B2716F82000A0D8FB /* CellFrameCacheContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB51DC3A2716F82000A0D8FB /* CellFrameCacheContainer.swift */; }; @@ -129,6 +130,7 @@ DB51DC5027181DE500A0D8FB /* CoverFlowStackMediaCollectionCell+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB51DC4F27181DE400A0D8FB /* CoverFlowStackMediaCollectionCell+ViewModel.swift */; }; DB522F1628869DAE0088017C /* Notification+Name+HandleTapAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE6358528869441001C114B /* Notification+Name+HandleTapAction.swift */; }; DB522F172886A08F0088017C /* UIStatusBarManager+HandleTapAction.m in Sources */ = {isa = PBXBuildFile; fileRef = DBE635832886940B001C114B /* UIStatusBarManager+HandleTapAction.m */; }; + DB55496029E01330004AF42A /* DataSourceProvider+UITableViewDataSourcePrefetching.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB55495F29E01330004AF42A /* DataSourceProvider+UITableViewDataSourcePrefetching.swift */; }; DB56329726DCBE1600FC893F /* StatusThreadViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB56329626DCBE1600FC893F /* StatusThreadViewController.swift */; }; DB56329A26DCBE7300FC893F /* StatusThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB56329926DCBE7300FC893F /* StatusThreadViewModel.swift */; }; DB56329C26DCC23700FC893F /* DataSourceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB56329B26DCC23700FC893F /* DataSourceProvider.swift */; }; @@ -148,6 +150,7 @@ DB580EB8288187BD00BC4A0F /* AccountPreferenceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB580EB3288187BD00BC4A0F /* AccountPreferenceViewController.swift */; }; DB580EB9288187BD00BC4A0F /* AccountPreferenceViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB580EB4288187BD00BC4A0F /* AccountPreferenceViewModel.swift */; }; DB581A0127D89A3700C35B91 /* DataSourceFacade+LIst.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB581A0027D89A3700C35B91 /* DataSourceFacade+LIst.swift */; }; + DB58F1EC298BD07400836FBE /* MeProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB58F1EB298BD07400836FBE /* MeProfileViewModel.swift */; }; DB5A2288255B9155006CA5B2 /* AccountListViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5A2287255B9155006CA5B2 /* AccountListViewModel+Diffable.swift */; }; DB5BF12327F5A549002A3EF5 /* PublishPostIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5BF12227F5A549002A3EF5 /* PublishPostIntentHandler.swift */; }; DB5BF12527F5A5C1002A3EF5 /* Account+Fetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5BF12427F5A5C1002A3EF5 /* Account+Fetch.swift */; }; @@ -157,11 +160,9 @@ DB5FB0102727FCC5006520FA /* SearchUserViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB67617A254C0279006C6798 /* SearchUserViewModel.swift */; }; DB5FB0112727FD02006520FA /* SearchUserViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDDE72D254C084A0057CF8E /* SearchUserViewModel+Diffable.swift */; }; DB5FB0122727FD4A006520FA /* SearchUserViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB578CD7254C0FB500745336 /* SearchUserViewModel+State.swift */; }; - DB5FD9B326D8D94600CF5439 /* StatusTableViewCell+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5FD9B226D8D94600CF5439 /* StatusTableViewCell+ViewModel.swift */; }; DB5FD9B726D8EFF700CF5439 /* UserBriefInfoView+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5FD9B626D8EFF700CF5439 /* UserBriefInfoView+ViewModel.swift */; }; DB657A7B25AD574D001339B6 /* UIBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB657A7A25AD574D001339B6 /* UIBarButtonItem.swift */; }; DB66DB8D2823A7C80071F5F3 /* SecondaryTabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66DB8C2823A7C80071F5F3 /* SecondaryTabBarController.swift */; }; - DB66DB902823AC3C0071F5F3 /* TabBarItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66DB8F2823AC3C0071F5F3 /* TabBarItem.swift */; }; DB67610D254AD681006C6798 /* ActivityIndicatorCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB67610C254AD681006C6798 /* ActivityIndicatorCollectionViewCell.swift */; }; DB676161254BE580006C6798 /* MediaPreviewCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB676160254BE580006C6798 /* MediaPreviewCollectionViewCell.swift */; }; DB697DF3278FDDF7004EF2F7 /* TimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB697DF2278FDDF7004EF2F7 /* TimelineViewModel.swift */; }; @@ -176,7 +177,6 @@ DB6BCD6E277ADC0900847054 /* TrendViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6BCD6D277ADC0900847054 /* TrendViewModel.swift */; }; DB6BCD70277AEAC700847054 /* TrendTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6BCD6F277AEAC700847054 /* TrendTableViewCell.swift */; }; DB6BCD72277AEED600847054 /* TrendViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6BCD71277AEED600847054 /* TrendViewModel+Diffable.swift */; }; - DB6CF1C4269715CD001DE069 /* FPSIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = DB6CF1C3269715CD001DE069 /* FPSIndicator */; }; DB6DF3E0252060AA00E8A273 /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6DF3DF252060AA00E8A273 /* ProfileViewModel.swift */; }; DB71C7D1271EB09A00BE3819 /* DataSourceFacade+Friendship.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71C7D0271EB09A00BE3819 /* DataSourceFacade+Friendship.swift */; }; DB71C7D3271EB71800BE3819 /* ProfileViewController+DataSourceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71C7D2271EB71800BE3819 /* ProfileViewController+DataSourceProvider.swift */; }; @@ -211,13 +211,11 @@ DB76A66F276083CB00A50673 /* MediaPreviewTransitionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB76A66E276083CB00A50673 /* MediaPreviewTransitionViewController.swift */; }; DB76A67127609A8700A50673 /* RemoteProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB76A67027609A8700A50673 /* RemoteProfileViewModel.swift */; }; DB77FF7F2847808C00182A0B /* ShareExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = DBBBBE5E2744E8CC007ACB4B /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - DB7AB33E2744E3740035EB8A /* Floaty in Frameworks */ = {isa = PBXBuildFile; productRef = DB7AB33D2744E3740035EB8A /* Floaty */; }; DB7FF06128853A7F00BFD55E /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7FF06028853A7F00BFD55E /* NotificationService.swift */; }; DB7FF06528853A7F00BFD55E /* NotificationService.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = DB7FF05E28853A7F00BFD55E /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; DB7FF06A28853AB000BFD55E /* String+Decode85.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7FF05928853A4F00BFD55E /* String+Decode85.swift */; }; DB7FF06B28853AB300BFD55E /* NotificationService+Decrypt.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7FF05628853A4F00BFD55E /* NotificationService+Decrypt.swift */; }; DB7FF06C28853AB800BFD55E /* String+Escape.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7FF05528853A4F00BFD55E /* String+Escape.swift */; }; - DB7FF06E28853B1F00BFD55E /* AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB94B6BB26C65CB000A2E8A1 /* AppShared.framework */; }; DB8301F7273CED0400BF5224 /* NotificationTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8301F6273CED0400BF5224 /* NotificationTimelineViewController.swift */; }; DB8301FA273CED2E00BF5224 /* NotificationTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8301F9273CED2E00BF5224 /* NotificationTimelineViewModel.swift */; }; DB830200273D04D000BF5224 /* NotificationTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8301FF273D04D000BF5224 /* NotificationTimelineViewModel+Diffable.swift */; }; @@ -225,22 +223,13 @@ DB83020D273D160400BF5224 /* NotificationTimelineViewModel+LoadOldestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB83020C273D160400BF5224 /* NotificationTimelineViewModel+LoadOldestState.swift */; }; DB8302142742180C00BF5224 /* NotificationTimelineViewController+DebugAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8302132742180C00BF5224 /* NotificationTimelineViewController+DebugAction.swift */; }; DB86433C26E898C5000C9879 /* DataSourceFacade+Like.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB86433B26E898C5000C9879 /* DataSourceFacade+Like.swift */; }; - DB8761BE274552F800BA7EE2 /* StatusMediaGallerySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8761AA2745515F00BA7EE2 /* StatusMediaGallerySection.swift */; }; - DB8761BF274552F800BA7EE2 /* StatusItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8761AD2745515F00BA7EE2 /* StatusItem.swift */; }; - DB8761C0274552F800BA7EE2 /* StatusSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8761AE2745515F00BA7EE2 /* StatusSection.swift */; }; - DB8761C1274552F800BA7EE2 /* UserItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8761A42745515F00BA7EE2 /* UserItem.swift */; }; - DB8761C2274552F800BA7EE2 /* UserSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8761A82745515F00BA7EE2 /* UserSection.swift */; }; - DB8761C3274552FB00BA7EE2 /* HashtagSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8761B22745515F00BA7EE2 /* HashtagSection.swift */; }; - DB8761C4274552FB00BA7EE2 /* HashtagData.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8761B12745515F00BA7EE2 /* HashtagData.swift */; }; - DB8761C5274552FB00BA7EE2 /* HashtagItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8761B32745515F00BA7EE2 /* HashtagItem.swift */; }; - DB8761C6274552FF00BA7EE2 /* NotificationItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8761B72745515F00BA7EE2 /* NotificationItem.swift */; }; - DB8761C7274552FF00BA7EE2 /* NotificationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8761B52745515F00BA7EE2 /* NotificationSection.swift */; }; - DB8761CB2745530200BA7EE2 /* CoverFlowStackSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8761BB2745515F00BA7EE2 /* CoverFlowStackSection.swift */; }; - DB8761CC2745530200BA7EE2 /* CoverFlowStackItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8761BC2745515F00BA7EE2 /* CoverFlowStackItem.swift */; }; - DB8761D02745553700BA7EE2 /* MediaSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8761CF2745553600BA7EE2 /* MediaSection.swift */; }; DB88AC3C250B26F40009E562 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB88AC3B250B26F40009E562 /* Preview Assets.xcassets */; }; - DB89F591257A2A70005ECD04 /* TimelineLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89F590257A2A70005ECD04 /* TimelineLoaderTableViewCell.swift */; }; + DB88E626288EA1F7009A01F5 /* DisplayPreferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB88E625288EA1F7009A01F5 /* DisplayPreferenceView.swift */; }; + DB88E6292890DF67009A01F5 /* BehaviorsPreferenceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB88E6282890DF67009A01F5 /* BehaviorsPreferenceViewController.swift */; }; + DB88E62C2890DF79009A01F5 /* BehaviorsPreferenceViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB88E62B2890DF79009A01F5 /* BehaviorsPreferenceViewModel.swift */; }; + DB88E62E2890DF7E009A01F5 /* BehaviorsPreferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB88E62D2890DF7E009A01F5 /* BehaviorsPreferenceView.swift */; }; DB8AC0F825401BA200E636BE /* UIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AC0F725401BA200E636BE /* UIViewController.swift */; }; + DB8BFB1C29D1F52900535092 /* TwidereXUITests+Performance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8BFB1B29D1F52900535092 /* TwidereXUITests+Performance.swift */; }; DB8E4FD92563CD6900459DA2 /* TwitterPinBasedAuthenticationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8E4FD82563CD6900459DA2 /* TwitterPinBasedAuthenticationViewController.swift */; }; DB8E4FEC2563D42800459DA2 /* TwitterPinBasedAuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8E4FEB2563D42800459DA2 /* TwitterPinBasedAuthenticationViewModel.swift */; }; DB914C4A26C3AC7C00E85F1A /* ContentOffsetFixedCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB914C4926C3AC7C00E85F1A /* ContentOffsetFixedCollectionView.swift */; }; @@ -248,13 +237,12 @@ DB92570A251C8FE0004FEFB5 /* ProfileHeaderViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB925709251C8FE0004FEFB5 /* ProfileHeaderViewController.swift */; }; DB925744251C9CD6004FEFB5 /* ProfilePagingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB925743251C9CD6004FEFB5 /* ProfilePagingViewController.swift */; }; DB925749251C9D48004FEFB5 /* ProfilePagingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB925748251C9D47004FEFB5 /* ProfilePagingViewModel.swift */; }; + DB92DB3F2898026B0011B564 /* UserHistoryViewController+DataSourceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB92DB3E2898026B0011B564 /* UserHistoryViewController+DataSourceProvider.swift */; }; + DB92DB41289804440011B564 /* UserHistoryViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB92DB40289804440011B564 /* UserHistoryViewModel+Diffable.swift */; }; DB932E5227FEC7390036A824 /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = DB932E5527FEC7390036A824 /* Intents.intentdefinition */; settings = {ATTRIBUTES = (codegen, ); }; }; DB932E5327FEC7390036A824 /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = DB932E5527FEC7390036A824 /* Intents.intentdefinition */; }; DB94B6B426C65BE100A2E8A1 /* MastodonAuthenticationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB94B6B326C65BE100A2E8A1 /* MastodonAuthenticationController.swift */; }; - DB94B6BE26C65CB000A2E8A1 /* AppShared.h in Headers */ = {isa = PBXBuildFile; fileRef = DB94B6BD26C65CB000A2E8A1 /* AppShared.h */; settings = {ATTRIBUTES = (Public, ); }; }; DB969A66253064FE0053CB31 /* DispatchQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB969A65253064FE0053CB31 /* DispatchQueue.swift */; }; - DB98DC0E2507862D0087E30F /* TimelineMiddleLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98DC0D2507862D0087E30F /* TimelineMiddleLoaderTableViewCell.swift */; }; - DB98DC10250787420087E30F /* TimelineBottomLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98DC0F250787420087E30F /* TimelineBottomLoaderTableViewCell.swift */; }; DB9B324D285732A400AC818D /* UserTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9B324C285732A400AC818D /* UserTimelineViewController.swift */; }; DB9B3251285735F200AC818D /* GridTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9B3250285735F200AC818D /* GridTimelineViewController.swift */; }; DB9B32542857369800AC818D /* GridTimelineViewController+DataSourceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9B32532857369800AC818D /* GridTimelineViewController+DataSourceProvider.swift */; }; @@ -282,10 +270,28 @@ DBB04A322861AE24003799CA /* TrendPlaceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB04A312861AE24003799CA /* TrendPlaceViewController.swift */; }; DBB04A352861AE4D003799CA /* TrendPlaceViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB04A342861AE4D003799CA /* TrendPlaceViewModel.swift */; }; DBB04A372861AF2D003799CA /* TrendPlaceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB04A362861AF2D003799CA /* TrendPlaceView.swift */; }; - DBB0E3C32760FB1200F1D45F /* TwidereSDK in Frameworks */ = {isa = PBXBuildFile; productRef = DBB0E3C22760FB1200F1D45F /* TwidereSDK */; }; DBB47DA926EB3AA2001590F7 /* ProfileFieldListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB47DA826EB3AA2001590F7 /* ProfileFieldListView.swift */; }; DBB47DAB26EB3BF8001590F7 /* ProfileFieldContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB47DAA26EB3BF8001590F7 /* ProfileFieldContentView.swift */; }; DBB47DAD26EB440C001590F7 /* ProfileFieldCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB47DAC26EB440C001590F7 /* ProfileFieldCollectionViewCell.swift */; }; + DBBA21152A403BAA00CEBCF8 /* TabBarItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1B802D2A4030AA00C90A7D /* TabBarItem.swift */; }; + DBBA21162A403BAF00CEBCF8 /* SidebarSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1B80232A4030AA00C90A7D /* SidebarSection.swift */; }; + DBBA21172A403BB100CEBCF8 /* SidebarItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1B80242A4030AA00C90A7D /* SidebarItem.swift */; }; + DBBA21182A403BB600CEBCF8 /* HashtagItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1B80282A4030AA00C90A7D /* HashtagItem.swift */; }; + DBBA21192A403BB600CEBCF8 /* HashtagSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1B80272A4030AA00C90A7D /* HashtagSection.swift */; }; + DBBA211A2A403BBA00CEBCF8 /* NotificationItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1B802B2A4030AA00C90A7D /* NotificationItem.swift */; }; + DBBA211B2A403BBA00CEBCF8 /* NotificationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1B802A2A4030AA00C90A7D /* NotificationSection.swift */; }; + DBBA211C2A403BBE00CEBCF8 /* CoverFlowStackSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1B802F2A4030AA00C90A7D /* CoverFlowStackSection.swift */; }; + DBBA211D2A403BBE00CEBCF8 /* CoverFlowStackItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1B80302A4030AA00C90A7D /* CoverFlowStackItem.swift */; }; + DBBA211E2A403BC500CEBCF8 /* SearchSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1B80322A4030AA00C90A7D /* SearchSection.swift */; }; + DBBA211F2A403BC500CEBCF8 /* SearchItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1B80332A4030AA00C90A7D /* SearchItem.swift */; }; + DBBA21202A403BC900CEBCF8 /* ListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1B80352A4030AA00C90A7D /* ListItem.swift */; }; + DBBA21212A403BC900CEBCF8 /* ListSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1B80362A4030AA00C90A7D /* ListSection.swift */; }; + DBBA21222A403BCC00CEBCF8 /* HistoryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1B80392A4030AA00C90A7D /* HistoryItem.swift */; }; + DBBA21232A403BCC00CEBCF8 /* HistorySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1B80382A4030AA00C90A7D /* HistorySection.swift */; }; + DBBA21242A403BCF00CEBCF8 /* MediaSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1B803B2A4030AA00C90A7D /* MediaSection.swift */; }; + DBBA21252A403BE100CEBCF8 /* StatusSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1B80422A4030AA00C90A7D /* StatusSection.swift */; }; + DBBA21262A403BE100CEBCF8 /* StatusItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1B80412A4030AA00C90A7D /* StatusItem.swift */; }; + DBBA21272A403BE100CEBCF8 /* StatusMediaGallerySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1B80402A4030AA00C90A7D /* StatusMediaGallerySection.swift */; }; DBBBBE592744E800007ACB4B /* ComposeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBBBE582744E800007ACB4B /* ComposeViewController.swift */; }; DBBBBE612744E8CC007ACB4B /* ComposeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBBBE602744E8CC007ACB4B /* ComposeViewController.swift */; }; DBBBBE642744E8CC007ACB4B /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = DBBBBE622744E8CC007ACB4B /* MainInterface.storyboard */; }; @@ -293,7 +299,6 @@ DBBBBE8D2744F9D9007ACB4B /* ComposeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBBBE8C2744F9D9007ACB4B /* ComposeViewModel.swift */; }; DBBBBE8F2744FB42007ACB4B /* ComposeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBBBE8E2744FB42007ACB4B /* ComposeViewModel.swift */; }; DBBF70F726D7B62F00971656 /* AccountListTableViewCell+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBF70F626D7B62F00971656 /* AccountListTableViewCell+ViewModel.swift */; }; - DBC17A5C26E0A4C1005C0D79 /* TimelineMiddleLoaderTableViewCell+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC17A5B26E0A4C1005C0D79 /* TimelineMiddleLoaderTableViewCell+ViewModel.swift */; }; DBC747E1259DBD5400787EEF /* AvatarBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC747E0259DBD5400787EEF /* AvatarBarButtonItem.swift */; }; DBC8E04B2576337F00401E20 /* DisposeBagCollectable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC8E04A2576337F00401E20 /* DisposeBagCollectable.swift */; }; DBC8E050257653E100401E20 /* SavePhotoActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC8E04F257653E100401E20 /* SavePhotoActivity.swift */; }; @@ -303,8 +308,6 @@ DBCB4053255B6EB100DD8D8F /* AccountListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCB4052255B6EB100DD8D8F /* AccountListViewModel.swift */; }; DBCB4060255CAC0300DD8D8F /* TwitterAuthenticationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCB405F255CAC0300DD8D8F /* TwitterAuthenticationController.swift */; }; DBCB408A255D8C2B00DD8D8F /* SafariActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCB4089255D8C2B00DD8D8F /* SafariActivity.swift */; }; - DBCC7AE5274BA10100E0986D /* DateTimeSwiftProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC7AE3274B95C900E0986D /* DateTimeSwiftProvider.swift */; }; - DBCC7AE6274BA10100E0986D /* OfficialTwitterTextProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB01091B26E606BB005F67D7 /* OfficialTwitterTextProvider.swift */; }; DBCE2E912591A06300926D09 /* UINavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCE2E902591A06300926D09 /* UINavigationController.swift */; }; DBCE2E992591A44000926D09 /* UIViewAnimatingPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCE2E982591A44000926D09 /* UIViewAnimatingPosition.swift */; }; DBD0B4972758B57F0015A388 /* DrawerSidebarTransitionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBEFDC91255920060086F268 /* DrawerSidebarTransitionController.swift */; }; @@ -312,10 +315,7 @@ DBD0B4992758B58F0015A388 /* DrawerSidebarAnimatedTransitioning.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB761E3E25592AA20050DC01 /* DrawerSidebarAnimatedTransitioning.swift */; }; DBD0B49A2758B5A60015A388 /* DrawerSidebarViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB761E69255946160050DC01 /* DrawerSidebarViewModel.swift */; }; DBD0B49B2758B5A60015A388 /* DrawerSidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBEFDC8725591F5C0086F268 /* DrawerSidebarViewController.swift */; }; - DBD0B49D2758B5F50015A388 /* SidebarSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD0B49C2758B5F50015A388 /* SidebarSection.swift */; }; - DBD0B4A02758B6010015A388 /* SidebarItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD0B49F2758B6010015A388 /* SidebarItem.swift */; }; DBD40B852599B9C2006E4ABC /* CombineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD40B842599B9C2006E4ABC /* CombineTests.swift */; }; - DBD98489251CB88A00ED87A1 /* Tabman in Frameworks */ = {isa = PBXBuildFile; productRef = DBD98488251CB88A00ED87A1 /* Tabman */; }; DBDA7F1A27D1CBCA00BA6BE1 /* TwidereXUITests+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDA7F1927D1CBCA00BA6BE1 /* TwidereXUITests+Onboarding.swift */; }; DBDA7F1E27D2256400BA6BE1 /* ListViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDA7F1D27D2256400BA6BE1 /* ListViewModel+State.swift */; }; DBDA8E2224FCF8A3006750DC /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDA8E2124FCF8A3006750DC /* AppDelegate.swift */; }; @@ -331,18 +331,16 @@ DBE6357A28855302001C114B /* PushNotificationScratchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE6357928855302001C114B /* PushNotificationScratchViewController.swift */; }; DBE6357D2885557C001C114B /* PushNotificationScratchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE6357C2885557C001C114B /* PushNotificationScratchViewModel.swift */; }; DBE6357F288555AE001C114B /* PushNotificationScratchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE6357E288555AE001C114B /* PushNotificationScratchView.swift */; }; - DBE71B7A26B7AF5C00DFAB8E /* StubTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE71B7926B7AF5C00DFAB8E /* StubTimelineViewController.swift */; }; - DBE71B7D26B7C5FD00DFAB8E /* StubTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE71B7C26B7C5FD00DFAB8E /* StubTimelineViewModel.swift */; }; - DBE71B7F26B7D68500DFAB8E /* StubTimelineCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE71B7E26B7D68500DFAB8E /* StubTimelineCollectionViewCell.swift */; }; DBE76D1E2500E65D00DEB0FC /* HomeTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE76D1D2500E65D00DEB0FC /* HomeTimelineViewModel.swift */; }; - DBEA4F842511F7460007FEC5 /* Kanna in Frameworks */ = {isa = PBXBuildFile; productRef = DBEA4F832511F7460007FEC5 /* Kanna */; }; + DBEA97632A1B556C00C8B75B /* SecondaryContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBEA97622A1B556C00C8B75B /* SecondaryContainerViewController.swift */; }; + DBEA97662A1B58E200C8B75B /* SecondaryContainerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBEA97652A1B58E200C8B75B /* SecondaryContainerViewModel.swift */; }; + DBEA97682A1B6D2300C8B75B /* NewColumnViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBEA97672A1B6D2300C8B75B /* NewColumnViewController.swift */; }; DBED96D8253F5D7800C5383A /* NamingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBED96D7253F5D7800C5383A /* NamingState.swift */; }; DBF167FD27C394830001F75E /* NeedsDependency+AvatarBarButtonItemDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF167FC27C394830001F75E /* NeedsDependency+AvatarBarButtonItemDelegate.swift */; }; DBF3309125B96E0B00A678FB /* WKNavigationDelegateShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF3309025B96E0B00A678FB /* WKNavigationDelegateShim.swift */; }; DBF3309925B988A500A678FB /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = DBF3309825B988A500A678FB /* Settings.bundle */; }; DBF639F2259B13BB009E12C8 /* TimelineHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF639F1259B13BB009E12C8 /* TimelineHeaderView.swift */; }; DBF639F7259B1728009E12C8 /* TimelineHeaderCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF639F6259B1728009E12C8 /* TimelineHeaderCollectionViewCell.swift */; }; - DBF639FC259B333A009E12C8 /* EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF639FB259B333A009E12C8 /* EmptyStateView.swift */; }; DBF63A04259B4811009E12C8 /* TouchBlockingCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF63A03259B4811009E12C8 /* TouchBlockingCollectionView.swift */; }; DBF63A38259D7CCB009E12C8 /* CornerSmoothPreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF63A37259D7CCB009E12C8 /* CornerSmoothPreviewViewController.swift */; }; DBF69EE02549705A00E2A915 /* ViewControllerAnimatedTransitioning.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF69EDE2549705A00E2A915 /* ViewControllerAnimatedTransitioning.swift */; }; @@ -350,29 +348,26 @@ DBF739CF275C247F00BF6AB5 /* DataSourceFacade+Mute.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF739CE275C247F00BF6AB5 /* DataSourceFacade+Mute.swift */; }; DBF739D1275C3EF300BF6AB5 /* DataSourceFacade+Report.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF739D0275C3EF300BF6AB5 /* DataSourceFacade+Report.swift */; }; DBF81C7C27F6A93E00004A56 /* DataSourceFacade+Translate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF81C7B27F6A93E00004A56 /* DataSourceFacade+Translate.swift */; }; - DBF81C7E27F6E7F500004A56 /* AppearancePreferenceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF81C7D27F6E7F500004A56 /* AppearancePreferenceViewController.swift */; }; - DBF81C8027F6E80700004A56 /* AppearancePreferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF81C7F27F6E80700004A56 /* AppearancePreferenceView.swift */; }; - DBF81C8327F6ECF400004A56 /* AppearancePreferenceViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF81C8227F6ECF400004A56 /* AppearancePreferenceViewModel.swift */; }; - DBF81C8527F709EF00004A56 /* TranslateButtonPreferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF81C8427F709EF00004A56 /* TranslateButtonPreferenceView.swift */; }; - DBF81C8827F7141600004A56 /* TranslationServicePreferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF81C8727F7141600004A56 /* TranslationServicePreferenceView.swift */; }; DBF81C8E27F843D700004A56 /* AppIconAssets.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF81C8C27F843D700004A56 /* AppIconAssets.swift */; }; - DBF81C9127F8448C00004A56 /* AppIconPreferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF81C9027F8448C00004A56 /* AppIconPreferenceView.swift */; }; - DBFA471C2859C33500C9FF7F /* MeLikeTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFA471B2859C33500C9FF7F /* MeLikeTimelineViewModel.swift */; }; + DBF87AD52892A6740029A7C7 /* AppIconPreferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF87AD42892A6740029A7C7 /* AppIconPreferenceView.swift */; }; + DBF87AD92892A67D0029A7C7 /* TranslateButtonPreferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF87AD72892A67D0029A7C7 /* TranslateButtonPreferenceView.swift */; }; + DBF87ADA2892A67D0029A7C7 /* TranslationServicePreferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF87AD82892A67D0029A7C7 /* TranslationServicePreferenceView.swift */; }; DBFA471F2859C4AE00C9FF7F /* UserLikeTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFA471E2859C4AE00C9FF7F /* UserLikeTimelineViewController.swift */; }; DBFA47212859C4F300C9FF7F /* UserLikeTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFA47202859C4F300C9FF7F /* UserLikeTimelineViewModel.swift */; }; DBFA4A2025A5924C00D51703 /* ListTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFA4A1F25A5924C00D51703 /* ListTimelineViewModel+Diffable.swift */; }; - DBFADA892872BEDA00B512D6 /* CoverFlowStackCollectionViewLayout in Frameworks */ = {isa = PBXBuildFile; productRef = DBFADA882872BEDA00B512D6 /* CoverFlowStackCollectionViewLayout */; }; DBFADA8B2872BEDE00B512D6 /* TabBarPager in Frameworks */ = {isa = PBXBuildFile; productRef = DBFADA8A2872BEDE00B512D6 /* TabBarPager */; }; - DBFC0AE6276118080011E99B /* AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB94B6BB26C65CB000A2E8A1 /* AppShared.framework */; }; - DBFC0AE9276118240011E99B /* AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB94B6BB26C65CB000A2E8A1 /* AppShared.framework */; }; - DBFC0AEA276118240011E99B /* AppShared.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = DB94B6BB26C65CB000A2E8A1 /* AppShared.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + DBFCA3F429F9185100B9DCA3 /* HomeListStatusTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFCA3F329F9185100B9DCA3 /* HomeListStatusTimelineViewController.swift */; }; + DBFCA3F729F9191F00B9DCA3 /* HostListStatusTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFCA3F629F9191F00B9DCA3 /* HostListStatusTimelineViewModel.swift */; }; DBFCC44725667C620016698E /* UILabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFCC44625667C620016698E /* UILabel.swift */; }; + DBFCEF1228939C9600EEBFB1 /* UserHistoryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFCEF1128939C9600EEBFB1 /* UserHistoryViewController.swift */; }; + DBFCEF1528939CAC00EEBFB1 /* UserHistoryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFCEF1428939CAC00EEBFB1 /* UserHistoryViewModel.swift */; }; + DBFCEF172893B92B00EEBFB1 /* StatusHistoryViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFCEF162893B92B00EEBFB1 /* StatusHistoryViewModel+Diffable.swift */; }; + DBFCEF192893BA8A00EEBFB1 /* StatusHistoryViewController+DataSourceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFCEF182893BA8A00EEBFB1 /* StatusHistoryViewController+DataSourceProvider.swift */; }; + DBFCEF202893E18400EEBFB1 /* DataSourceFacade+History.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFCEF1F2893E18400EEBFB1 /* DataSourceFacade+History.swift */; }; DBFDCE4127F450FC00BE99E3 /* Intents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DBFDCE0327F446BB00BE99E3 /* Intents.framework */; }; DBFDCE4427F450FC00BE99E3 /* IntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFDCE4327F450FC00BE99E3 /* IntentHandler.swift */; }; DBFDCE4827F450FC00BE99E3 /* TwidereXIntent.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = DBFDCE4027F450FC00BE99E3 /* TwidereXIntent.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; DBFDCE5027F4515E00BE99E3 /* SwitchAccountIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFDCE4F27F4515E00BE99E3 /* SwitchAccountIntentHandler.swift */; }; - DBFDCE5427F451FC00BE99E3 /* AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB94B6BB26C65CB000A2E8A1 /* AppShared.framework */; }; - E5E65DC85CA480AE17571EFC /* Pods_AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 84835D7E14A262E389DD4AB3 /* Pods_AppShared.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -390,27 +385,6 @@ remoteGlobalIDString = DB7FF05D28853A7F00BFD55E; remoteInfo = NotificationService; }; - DB7FF07028853B1F00BFD55E /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = DBDA8E1624FCF8A3006750DC /* Project object */; - proxyType = 1; - remoteGlobalIDString = DB94B6BA26C65CB000A2E8A1; - remoteInfo = AppShared; - }; - DB94B6BF26C65CB000A2E8A1 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = DBDA8E1624FCF8A3006750DC /* Project object */; - proxyType = 1; - remoteGlobalIDString = DB94B6BA26C65CB000A2E8A1; - remoteInfo = AppShared; - }; - DBBBBE732744EC27007ACB4B /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = DBDA8E1624FCF8A3006750DC /* Project object */; - proxyType = 1; - remoteGlobalIDString = DB94B6BA26C65CB000A2E8A1; - remoteInfo = AppShared; - }; DBBBBE812744F39C007ACB4B /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = DBDA8E1624FCF8A3006750DC /* Project object */; @@ -446,33 +420,15 @@ remoteGlobalIDString = DBFDCE3F27F450FC00BE99E3; remoteInfo = TwidereXIntent; }; - DBFDCE5627F451FC00BE99E3 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = DBDA8E1624FCF8A3006750DC /* Project object */; - proxyType = 1; - remoteGlobalIDString = DB94B6BA26C65CB000A2E8A1; - remoteInfo = AppShared; - }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ - DB0B3B8D26C6637500501BB7 /* Embed Frameworks */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Embed Frameworks"; - runOnlyForDeploymentPostprocessing = 0; - }; DBFC0AEB276118240011E99B /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 10; files = ( - DBFC0AEA276118240011E99B /* AppShared.framework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -519,7 +475,6 @@ D286181E8236434CBB750613 /* Pods-TwidereXTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TwidereXTests.profile.xcconfig"; path = "Target Support Files/Pods-TwidereXTests/Pods-TwidereXTests.profile.xcconfig"; sourceTree = ""; }; DB004BF526CE4A7F00F5C574 /* StatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCollectionViewCell.swift; sourceTree = ""; }; DB01091426E5EB64005F67D7 /* MastodonStatusThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonStatusThreadViewModel.swift; sourceTree = ""; }; - DB01091B26E606BB005F67D7 /* OfficialTwitterTextProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfficialTwitterTextProvider.swift; sourceTree = ""; }; DB01091F26E60756005F67D7 /* DataSourceFacade+StatusThread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+StatusThread.swift"; sourceTree = ""; }; DB01092126E608B7005F67D7 /* DataSourceFacade+Repost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Repost.swift"; sourceTree = ""; }; DB01092326E6199C005F67D7 /* DataSourceProvider+StatusViewTableViewCellDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceProvider+StatusViewTableViewCellDelegate.swift"; sourceTree = ""; }; @@ -529,10 +484,10 @@ DB01A9A327637FE60055FABC /* DataSourceFacade+Meta.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Meta.swift"; sourceTree = ""; }; DB02C76C27350D71007EA0BF /* SearchHashtagViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHashtagViewController.swift; sourceTree = ""; }; DB02C76F27350D8A007EA0BF /* SearchHashtagViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHashtagViewModel.swift; sourceTree = ""; }; - DB02C77127351B7D007EA0BF /* HashtagTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTableViewCell.swift; sourceTree = ""; }; - DB02C77427351DA2007EA0BF /* HashtagTableViewCell+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTableViewCell+ViewModel.swift"; sourceTree = ""; }; DB02C776273520B8007EA0BF /* SearchHashtagViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchHashtagViewModel+State.swift"; sourceTree = ""; }; DB02C77827352217007EA0BF /* SearchHashtagViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchHashtagViewModel+Diffable.swift"; sourceTree = ""; }; + DB0455B52A1CA2F3009A00EF /* NewColumnViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewColumnViewModel.swift; sourceTree = ""; }; + DB0455B72A1CA510009A00EF /* NewColumnView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewColumnView.swift; sourceTree = ""; }; DB06180F2786EC870030EE79 /* LineChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LineChartView.swift; sourceTree = ""; }; DB0AD4DE285872BE0002ABDB /* UserMediaTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserMediaTimelineViewController.swift; sourceTree = ""; }; DB0AD4E12858734A0002ABDB /* UserMediaTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserMediaTimelineViewModel.swift; sourceTree = ""; }; @@ -546,11 +501,34 @@ DB148B02281A7AB300B596C7 /* ContentSplitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentSplitViewController.swift; sourceTree = ""; }; DB148B04281A81AE00B596C7 /* SidebarViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarViewModel.swift; sourceTree = ""; }; DB148B0B281A837E00B596C7 /* SidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarView.swift; sourceTree = ""; }; + DB1B80232A4030AA00C90A7D /* SidebarSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSection.swift; sourceTree = ""; }; + DB1B80242A4030AA00C90A7D /* SidebarItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarItem.swift; sourceTree = ""; }; + DB1B80272A4030AA00C90A7D /* HashtagSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagSection.swift; sourceTree = ""; }; + DB1B80282A4030AA00C90A7D /* HashtagItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagItem.swift; sourceTree = ""; }; + DB1B802A2A4030AA00C90A7D /* NotificationSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSection.swift; sourceTree = ""; }; + DB1B802B2A4030AA00C90A7D /* NotificationItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationItem.swift; sourceTree = ""; }; + DB1B802D2A4030AA00C90A7D /* TabBarItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarItem.swift; sourceTree = ""; }; + DB1B802F2A4030AA00C90A7D /* CoverFlowStackSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoverFlowStackSection.swift; sourceTree = ""; }; + DB1B80302A4030AA00C90A7D /* CoverFlowStackItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoverFlowStackItem.swift; sourceTree = ""; }; + DB1B80322A4030AA00C90A7D /* SearchSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchSection.swift; sourceTree = ""; }; + DB1B80332A4030AA00C90A7D /* SearchItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchItem.swift; sourceTree = ""; }; + DB1B80352A4030AA00C90A7D /* ListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListItem.swift; sourceTree = ""; }; + DB1B80362A4030AA00C90A7D /* ListSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListSection.swift; sourceTree = ""; }; + DB1B80382A4030AA00C90A7D /* HistorySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistorySection.swift; sourceTree = ""; }; + DB1B80392A4030AA00C90A7D /* HistoryItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryItem.swift; sourceTree = ""; }; + DB1B803B2A4030AA00C90A7D /* MediaSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSection.swift; sourceTree = ""; }; + DB1B80402A4030AA00C90A7D /* StatusMediaGallerySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusMediaGallerySection.swift; sourceTree = ""; }; + DB1B80412A4030AA00C90A7D /* StatusItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusItem.swift; sourceTree = ""; }; + DB1B80422A4030AA00C90A7D /* StatusSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusSection.swift; sourceTree = ""; }; + DB1B80442A403B0A00C90A7D /* UserItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserItem.swift; sourceTree = ""; }; + DB1B80452A403B0A00C90A7D /* UserSection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserSection.swift; sourceTree = ""; }; + DB1D3DE9289388CD008F0BD0 /* HistoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryViewController.swift; sourceTree = ""; }; + DB1D3DEC28938ACD008F0BD0 /* HistoryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryViewModel.swift; sourceTree = ""; }; + DB1D3DEE28938CD1008F0BD0 /* StatusHistoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusHistoryViewController.swift; sourceTree = ""; }; + DB1D3DF128938CDF008F0BD0 /* StatusHistoryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusHistoryViewModel.swift; sourceTree = ""; }; DB1D7B4225B5938300397DCD /* TwitterAuthenticationOptionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwitterAuthenticationOptionViewController.swift; sourceTree = ""; }; DB1D7B5425B5A87200397DCD /* TwitterAuthenticationOptionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwitterAuthenticationOptionViewModel.swift; sourceTree = ""; }; DB1E48132772CE850074F6A0 /* SearchViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewModel+Diffable.swift"; sourceTree = ""; }; - DB1E48152772CEC20074F6A0 /* SearchSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchSection.swift; sourceTree = ""; }; - DB1E48182772CECC0074F6A0 /* SearchItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchItem.swift; sourceTree = ""; }; DB235EF32834DD0900398FCA /* SettingListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingListViewModel.swift; sourceTree = ""; }; DB235EF52834DDD200398FCA /* AboutViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutViewModel.swift; sourceTree = ""; }; DB25C4C427798E1A00EC1435 /* DataSourceFacade+SavedSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+SavedSearch.swift"; sourceTree = ""; }; @@ -583,15 +561,11 @@ DB2D36F827D5E74D00C1FBE0 /* CompositeListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositeListViewModel.swift; sourceTree = ""; }; DB2EBBEF255D368200956CAA /* TableViewEntryRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewEntryRow.swift; sourceTree = ""; }; DB2FFF3D258B78B0003DBC19 /* AVPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVPlayer.swift; sourceTree = ""; }; - DB30ADDB26CFC7EE00B2D2BE /* StatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewCell.swift; sourceTree = ""; }; DB33A4A825A319A0003CED7D /* ActionToolbarContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionToolbarContainer.swift; sourceTree = ""; }; - DB33E5B42719637400EC2225 /* CoverFlowStackCollectionViewLayout */ = {isa = PBXFileReference; lastKnownFileType = folder; path = CoverFlowStackCollectionViewLayout; sourceTree = ""; }; - DB34029C2521BE8B009EFADF /* MeProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeProfileViewModel.swift; sourceTree = ""; }; DB36F35D257F74C00028F81E /* ScrollViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewContainer.swift; sourceTree = ""; }; DB36F375257F79DB0028F81E /* SearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = ""; }; DB37F69F274B556B0081603F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; DB3B905E26E8A9650010F64C /* StatusThreadViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusThreadViewController+DataSourceProvider.swift"; sourceTree = ""; }; - DB3B906026E8AB480010F64C /* StatusViewTableViewCellDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusViewTableViewCellDelegate.swift; sourceTree = ""; }; DB3B906226E8BBD70010F64C /* ProfileHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderView.swift; sourceTree = ""; }; DB3B906426E8BF1F0010F64C /* ProfileHeaderView+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileHeaderView+ViewModel.swift"; sourceTree = ""; }; DB3B906626E8CD6D0010F64C /* ProfileHeaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderViewModel.swift; sourceTree = ""; }; @@ -625,10 +599,7 @@ DB46D12227DB6223003B8BA1 /* ListUserViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ListUserViewController+DataSourceProvider.swift"; sourceTree = ""; }; DB47AB1927CCB7EC00CD73C7 /* ListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListViewController.swift; sourceTree = ""; }; DB47AB1C27CCB88000CD73C7 /* ListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListViewModel.swift; sourceTree = ""; }; - DB47AB1F27CCC18500CD73C7 /* ListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListItem.swift; sourceTree = ""; }; - DB47AB2027CCC18500CD73C7 /* ListSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListSection.swift; sourceTree = ""; }; DB47AB2C27CE085800CD73C7 /* ListViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ListViewModel+Diffable.swift"; sourceTree = ""; }; - DB47AB3D27CE1F9E00CD73C7 /* TimelineTopLoaderTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TimelineTopLoaderTableViewCell.swift; sourceTree = ""; }; DB51DC192715581E00A0D8FB /* ProfileDashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileDashboardView.swift; sourceTree = ""; }; DB51DC1B2715588E00A0D8FB /* ProfileDashboardMeterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileDashboardMeterView.swift; sourceTree = ""; }; DB51DC372716AEF000A0D8FB /* TabBarPager */ = {isa = PBXFileReference; lastKnownFileType = folder; path = TabBarPager; sourceTree = ""; }; @@ -637,6 +608,7 @@ DB51DC422718117900A0D8FB /* StatusMediaGalleryCollectionCell+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusMediaGalleryCollectionCell+ViewModel.swift"; sourceTree = ""; }; DB51DC4D27181D7500A0D8FB /* CoverFlowStackMediaCollectionCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoverFlowStackMediaCollectionCell.swift; sourceTree = ""; }; DB51DC4F27181DE400A0D8FB /* CoverFlowStackMediaCollectionCell+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CoverFlowStackMediaCollectionCell+ViewModel.swift"; sourceTree = ""; }; + DB55495F29E01330004AF42A /* DataSourceProvider+UITableViewDataSourcePrefetching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceProvider+UITableViewDataSourcePrefetching.swift"; sourceTree = ""; }; DB56329626DCBE1600FC893F /* StatusThreadViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusThreadViewController.swift; sourceTree = ""; }; DB56329926DCBE7300FC893F /* StatusThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusThreadViewModel.swift; sourceTree = ""; }; DB56329B26DCC23700FC893F /* DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataSourceProvider.swift; sourceTree = ""; }; @@ -656,18 +628,17 @@ DB580EB3288187BD00BC4A0F /* AccountPreferenceViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountPreferenceViewController.swift; sourceTree = ""; }; DB580EB4288187BD00BC4A0F /* AccountPreferenceViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountPreferenceViewModel.swift; sourceTree = ""; }; DB581A0027D89A3700C35B91 /* DataSourceFacade+LIst.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+LIst.swift"; sourceTree = ""; }; + DB58F1EB298BD07400836FBE /* MeProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeProfileViewModel.swift; sourceTree = ""; }; DB5918F6255E81FB00B20F6F /* MediaPreviewViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MediaPreviewViewController+DataSourceProvider.swift"; sourceTree = ""; }; DB5A2287255B9155006CA5B2 /* AccountListViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccountListViewModel+Diffable.swift"; sourceTree = ""; }; DB5BF12227F5A549002A3EF5 /* PublishPostIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublishPostIntentHandler.swift; sourceTree = ""; }; DB5BF12427F5A5C1002A3EF5 /* Account+Fetch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Account+Fetch.swift"; sourceTree = ""; }; DB5BF12C27F5BAD5002A3EF5 /* AuthenticationIndex+Fetch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AuthenticationIndex+Fetch.swift"; sourceTree = ""; }; DB5F1C132886AE2F00978F38 /* TwidereXTests+Issue92.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TwidereXTests+Issue92.swift"; sourceTree = ""; }; - DB5FD9B226D8D94600CF5439 /* StatusTableViewCell+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusTableViewCell+ViewModel.swift"; sourceTree = ""; }; DB5FD9B626D8EFF700CF5439 /* UserBriefInfoView+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserBriefInfoView+ViewModel.swift"; sourceTree = ""; }; DB60C1A82762394E00628235 /* AuthenticationServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AuthenticationServices.framework; path = System/Library/Frameworks/AuthenticationServices.framework; sourceTree = SDKROOT; }; DB657A7A25AD574D001339B6 /* UIBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIBarButtonItem.swift; sourceTree = ""; }; DB66DB8C2823A7C80071F5F3 /* SecondaryTabBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecondaryTabBarController.swift; sourceTree = ""; }; - DB66DB8F2823AC3C0071F5F3 /* TabBarItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarItem.swift; sourceTree = ""; }; DB67610C254AD681006C6798 /* ActivityIndicatorCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityIndicatorCollectionViewCell.swift; sourceTree = ""; }; DB676160254BE580006C6798 /* MediaPreviewCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewCollectionViewCell.swift; sourceTree = ""; }; DB67616B254C021A006C6798 /* SearchUserViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchUserViewController.swift; sourceTree = ""; }; @@ -722,23 +693,14 @@ DB83020C273D160400BF5224 /* NotificationTimelineViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationTimelineViewModel+LoadOldestState.swift"; sourceTree = ""; }; DB8302132742180C00BF5224 /* NotificationTimelineViewController+DebugAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationTimelineViewController+DebugAction.swift"; sourceTree = ""; }; DB86433B26E898C5000C9879 /* DataSourceFacade+Like.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Like.swift"; sourceTree = ""; }; - DB8761A42745515F00BA7EE2 /* UserItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserItem.swift; sourceTree = ""; }; - DB8761A82745515F00BA7EE2 /* UserSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSection.swift; sourceTree = ""; }; - DB8761AA2745515F00BA7EE2 /* StatusMediaGallerySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusMediaGallerySection.swift; sourceTree = ""; }; - DB8761AD2745515F00BA7EE2 /* StatusItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusItem.swift; sourceTree = ""; }; - DB8761AE2745515F00BA7EE2 /* StatusSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusSection.swift; sourceTree = ""; }; - DB8761B12745515F00BA7EE2 /* HashtagData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagData.swift; sourceTree = ""; }; - DB8761B22745515F00BA7EE2 /* HashtagSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagSection.swift; sourceTree = ""; }; - DB8761B32745515F00BA7EE2 /* HashtagItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagItem.swift; sourceTree = ""; }; - DB8761B52745515F00BA7EE2 /* NotificationSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSection.swift; sourceTree = ""; }; - DB8761B72745515F00BA7EE2 /* NotificationItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationItem.swift; sourceTree = ""; }; - DB8761BB2745515F00BA7EE2 /* CoverFlowStackSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoverFlowStackSection.swift; sourceTree = ""; }; - DB8761BC2745515F00BA7EE2 /* CoverFlowStackItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoverFlowStackItem.swift; sourceTree = ""; }; - DB8761CF2745553600BA7EE2 /* MediaSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSection.swift; sourceTree = ""; }; DB88AC3B250B26F40009E562 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; - DB89F590257A2A70005ECD04 /* TimelineLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLoaderTableViewCell.swift; sourceTree = ""; }; + DB88E625288EA1F7009A01F5 /* DisplayPreferenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayPreferenceView.swift; sourceTree = ""; }; + DB88E6282890DF67009A01F5 /* BehaviorsPreferenceViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BehaviorsPreferenceViewController.swift; sourceTree = ""; }; + DB88E62B2890DF79009A01F5 /* BehaviorsPreferenceViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BehaviorsPreferenceViewModel.swift; sourceTree = ""; }; + DB88E62D2890DF7E009A01F5 /* BehaviorsPreferenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BehaviorsPreferenceView.swift; sourceTree = ""; }; DB8AC0F725401BA200E636BE /* UIViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewController.swift; sourceTree = ""; }; DB8AC18F2542DA9500E636BE /* ActiveLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveLabel.swift; sourceTree = ""; }; + DB8BFB1B29D1F52900535092 /* TwidereXUITests+Performance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TwidereXUITests+Performance.swift"; sourceTree = ""; }; DB8E4FD82563CD6900459DA2 /* TwitterPinBasedAuthenticationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwitterPinBasedAuthenticationViewController.swift; sourceTree = ""; }; DB8E4FEB2563D42800459DA2 /* TwitterPinBasedAuthenticationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwitterPinBasedAuthenticationViewModel.swift; sourceTree = ""; }; DB914C4926C3AC7C00E85F1A /* ContentOffsetFixedCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentOffsetFixedCollectionView.swift; sourceTree = ""; }; @@ -746,6 +708,8 @@ DB925709251C8FE0004FEFB5 /* ProfileHeaderViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderViewController.swift; sourceTree = ""; }; DB925743251C9CD6004FEFB5 /* ProfilePagingViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewController.swift; sourceTree = ""; }; DB925748251C9D47004FEFB5 /* ProfilePagingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewModel.swift; sourceTree = ""; }; + DB92DB3E2898026B0011B564 /* UserHistoryViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserHistoryViewController+DataSourceProvider.swift"; sourceTree = ""; }; + DB92DB40289804440011B564 /* UserHistoryViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserHistoryViewModel+Diffable.swift"; sourceTree = ""; }; DB932E5427FEC7390036A824 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.intentdefinition; name = Base; path = Base.lproj/Intents.intentdefinition; sourceTree = ""; }; DB932E5727FEC73B0036A824 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Intents.strings; sourceTree = ""; }; DB932E5927FEC73C0036A824 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Intents.strings; sourceTree = ""; }; @@ -760,12 +724,8 @@ DB932E6B27FEC7490036A824 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Intents.strings"; sourceTree = ""; }; DB932E6D27FEC7490036A824 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Intents.strings; sourceTree = ""; }; DB94B6B326C65BE100A2E8A1 /* MastodonAuthenticationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAuthenticationController.swift; sourceTree = ""; }; - DB94B6BB26C65CB000A2E8A1 /* AppShared.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AppShared.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - DB94B6BD26C65CB000A2E8A1 /* AppShared.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AppShared.h; sourceTree = ""; }; DB969A65253064FE0053CB31 /* DispatchQueue.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DispatchQueue.swift; sourceTree = ""; }; DB97D1F4256CF7710056F8C2 /* ProgressBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressBarView.swift; sourceTree = ""; }; - DB98DC0D2507862D0087E30F /* TimelineMiddleLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMiddleLoaderTableViewCell.swift; sourceTree = ""; }; - DB98DC0F250787420087E30F /* TimelineBottomLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBottomLoaderTableViewCell.swift; sourceTree = ""; }; DB9B324C285732A400AC818D /* UserTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserTimelineViewController.swift; sourceTree = ""; }; DB9B3250285735F200AC818D /* GridTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridTimelineViewController.swift; sourceTree = ""; }; DB9B32532857369800AC818D /* GridTimelineViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GridTimelineViewController+DataSourceProvider.swift"; sourceTree = ""; }; @@ -815,7 +775,6 @@ DBBBBE8C2744F9D9007ACB4B /* ComposeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewModel.swift; sourceTree = ""; }; DBBBBE8E2744FB42007ACB4B /* ComposeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewModel.swift; sourceTree = ""; }; DBBF70F626D7B62F00971656 /* AccountListTableViewCell+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccountListTableViewCell+ViewModel.swift"; sourceTree = ""; }; - DBC17A5B26E0A4C1005C0D79 /* TimelineMiddleLoaderTableViewCell+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimelineMiddleLoaderTableViewCell+ViewModel.swift"; sourceTree = ""; }; DBC747E0259DBD5400787EEF /* AvatarBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarBarButtonItem.swift; sourceTree = ""; }; DBC8E04A2576337F00401E20 /* DisposeBagCollectable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisposeBagCollectable.swift; sourceTree = ""; }; DBC8E04F257653E100401E20 /* SavePhotoActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavePhotoActivity.swift; sourceTree = ""; }; @@ -826,16 +785,12 @@ DBCB4052255B6EB100DD8D8F /* AccountListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountListViewModel.swift; sourceTree = ""; }; DBCB405F255CAC0300DD8D8F /* TwitterAuthenticationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwitterAuthenticationController.swift; sourceTree = ""; }; DBCB4089255D8C2B00DD8D8F /* SafariActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariActivity.swift; sourceTree = ""; }; - DBCC7AE3274B95C900E0986D /* DateTimeSwiftProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateTimeSwiftProvider.swift; sourceTree = ""; }; - DBCDCE602760B8B000F0B78C /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; DBCE2E902591A06300926D09 /* UINavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UINavigationController.swift; sourceTree = ""; }; DBCE2E982591A44000926D09 /* UIViewAnimatingPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewAnimatingPosition.swift; sourceTree = ""; }; DBCE2EA82591F23B00926D09 /* FollowingListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowingListViewController.swift; sourceTree = ""; }; DBCE2EB12591F38100926D09 /* FriendshipListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendshipListViewModel.swift; sourceTree = ""; }; DBCE2EC72591F74300926D09 /* FollowingListViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowingListViewModel+Diffable.swift"; sourceTree = ""; }; DBCE2ED72591FDF000926D09 /* FollowingListViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowingListViewModel+State.swift"; sourceTree = ""; }; - DBD0B49C2758B5F50015A388 /* SidebarSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSection.swift; sourceTree = ""; }; - DBD0B49F2758B6010015A388 /* SidebarItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarItem.swift; sourceTree = ""; }; DBD40B842599B9C2006E4ABC /* CombineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombineTests.swift; sourceTree = ""; }; DBD40BEE2599CB89006E4ABC /* FollowerListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowerListViewController.swift; sourceTree = ""; }; DBD8B53D25E3C3C5006299ED /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -858,25 +813,18 @@ DBDA8E9724FE075E006750DC /* MainTabBarController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainTabBarController.swift; sourceTree = ""; }; DBDA8E9E24FE0FFF006750DC /* HomeTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineViewController.swift; sourceTree = ""; }; DBDDE72D254C084A0057CF8E /* SearchUserViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchUserViewModel+Diffable.swift"; sourceTree = ""; }; + DBE399D829EFE3A7008FA278 /* CoverFlowStackLayout */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = CoverFlowStackLayout; sourceTree = ""; }; DBE6357928855302001C114B /* PushNotificationScratchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationScratchViewController.swift; sourceTree = ""; }; DBE6357C2885557C001C114B /* PushNotificationScratchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationScratchViewModel.swift; sourceTree = ""; }; DBE6357E288555AE001C114B /* PushNotificationScratchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationScratchView.swift; sourceTree = ""; }; DBE635822886940A001C114B /* TwidereX-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "TwidereX-Bridging-Header.h"; sourceTree = ""; }; DBE635832886940B001C114B /* UIStatusBarManager+HandleTapAction.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UIStatusBarManager+HandleTapAction.m"; sourceTree = ""; }; DBE6358528869441001C114B /* Notification+Name+HandleTapAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+Name+HandleTapAction.swift"; sourceTree = ""; }; - DBE71B7926B7AF5C00DFAB8E /* StubTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubTimelineViewController.swift; sourceTree = ""; }; - DBE71B7C26B7C5FD00DFAB8E /* StubTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubTimelineViewModel.swift; sourceTree = ""; }; - DBE71B7E26B7D68500DFAB8E /* StubTimelineCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubTimelineCollectionViewCell.swift; sourceTree = ""; }; DBE76CD2250095D900DEB0FC /* TwidereX.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TwidereX.entitlements; sourceTree = ""; }; - DBE76CEA2500B29300DEB0FC /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - DBE76CEC2500B29300DEB0FC /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; - DBE76CEE2500B29300DEB0FC /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; - DBE76CF12500B29300DEB0FC /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; - DBE76CF32500B29500DEB0FC /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - DBE76CF62500B29500DEB0FC /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; - DBE76CF82500B29500DEB0FC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - DBE76CFE2500B43300DEB0FC /* StubMixer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubMixer.swift; sourceTree = ""; }; DBE76D1D2500E65D00DEB0FC /* HomeTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineViewModel.swift; sourceTree = ""; }; + DBEA97622A1B556C00C8B75B /* SecondaryContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecondaryContainerViewController.swift; sourceTree = ""; }; + DBEA97652A1B58E200C8B75B /* SecondaryContainerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecondaryContainerViewModel.swift; sourceTree = ""; }; + DBEA97672A1B6D2300C8B75B /* NewColumnViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewColumnViewController.swift; sourceTree = ""; }; DBED2A7125B8006400BE6941 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/InfoPlist.strings; sourceTree = ""; }; DBED96D7253F5D7800C5383A /* NamingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NamingState.swift; sourceTree = ""; }; DBEFDC8725591F5C0086F268 /* DrawerSidebarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DrawerSidebarViewController.swift; sourceTree = ""; }; @@ -886,7 +834,6 @@ DBF3309825B988A500A678FB /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; DBF639F1259B13BB009E12C8 /* TimelineHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineHeaderView.swift; sourceTree = ""; }; DBF639F6259B1728009E12C8 /* TimelineHeaderCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineHeaderCollectionViewCell.swift; sourceTree = ""; }; - DBF639FB259B333A009E12C8 /* EmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyStateView.swift; sourceTree = ""; }; DBF63A03259B4811009E12C8 /* TouchBlockingCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TouchBlockingCollectionView.swift; sourceTree = ""; }; DBF63A37259D7CCB009E12C8 /* CornerSmoothPreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CornerSmoothPreviewViewController.swift; sourceTree = ""; }; DBF69E8E2549601900E2A915 /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = ""; }; @@ -900,19 +847,22 @@ DBF81C7327F68AA800004A56 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/InfoPlist.strings; sourceTree = ""; }; DBF81C7A27F696E000004A56 /* gl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = gl; path = gl.lproj/InfoPlist.strings; sourceTree = ""; }; DBF81C7B27F6A93E00004A56 /* DataSourceFacade+Translate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Translate.swift"; sourceTree = ""; }; - DBF81C7D27F6E7F500004A56 /* AppearancePreferenceViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearancePreferenceViewController.swift; sourceTree = ""; }; - DBF81C7F27F6E80700004A56 /* AppearancePreferenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearancePreferenceView.swift; sourceTree = ""; }; - DBF81C8227F6ECF400004A56 /* AppearancePreferenceViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearancePreferenceViewModel.swift; sourceTree = ""; }; - DBF81C8427F709EF00004A56 /* TranslateButtonPreferenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslateButtonPreferenceView.swift; sourceTree = ""; }; - DBF81C8727F7141600004A56 /* TranslationServicePreferenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationServicePreferenceView.swift; sourceTree = ""; }; DBF81C8C27F843D700004A56 /* AppIconAssets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppIconAssets.swift; sourceTree = ""; }; - DBF81C9027F8448C00004A56 /* AppIconPreferenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconPreferenceView.swift; sourceTree = ""; }; - DBFA471B2859C33500C9FF7F /* MeLikeTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeLikeTimelineViewModel.swift; sourceTree = ""; }; + DBF87AD42892A6740029A7C7 /* AppIconPreferenceView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppIconPreferenceView.swift; sourceTree = ""; }; + DBF87AD72892A67D0029A7C7 /* TranslateButtonPreferenceView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TranslateButtonPreferenceView.swift; sourceTree = ""; }; + DBF87AD82892A67D0029A7C7 /* TranslationServicePreferenceView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TranslationServicePreferenceView.swift; sourceTree = ""; }; DBFA471E2859C4AE00C9FF7F /* UserLikeTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserLikeTimelineViewController.swift; sourceTree = ""; }; DBFA47202859C4F300C9FF7F /* UserLikeTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserLikeTimelineViewModel.swift; sourceTree = ""; }; DBFA4A1F25A5924C00D51703 /* ListTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ListTimelineViewModel+Diffable.swift"; sourceTree = ""; }; + DBFCA3F329F9185100B9DCA3 /* HomeListStatusTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeListStatusTimelineViewController.swift; sourceTree = ""; }; + DBFCA3F629F9191F00B9DCA3 /* HostListStatusTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostListStatusTimelineViewModel.swift; sourceTree = ""; }; DBFCC44625667C620016698E /* UILabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UILabel.swift; sourceTree = ""; }; DBFCC46225668B860016698E /* DrawerSidebarPresentationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DrawerSidebarPresentationController.swift; sourceTree = ""; }; + DBFCEF1128939C9600EEBFB1 /* UserHistoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserHistoryViewController.swift; sourceTree = ""; }; + DBFCEF1428939CAC00EEBFB1 /* UserHistoryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserHistoryViewModel.swift; sourceTree = ""; }; + DBFCEF162893B92B00EEBFB1 /* StatusHistoryViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusHistoryViewModel+Diffable.swift"; sourceTree = ""; }; + DBFCEF182893BA8A00EEBFB1 /* StatusHistoryViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusHistoryViewController+DataSourceProvider.swift"; sourceTree = ""; }; + DBFCEF1F2893E18400EEBFB1 /* DataSourceFacade+History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+History.swift"; sourceTree = ""; }; DBFDCE0327F446BB00BE99E3 /* Intents.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Intents.framework; path = System/Library/Frameworks/Intents.framework; sourceTree = SDKROOT; }; DBFDCE2227F44C7100BE99E3 /* IntentsUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IntentsUI.framework; path = System/Library/Frameworks/IntentsUI.framework; sourceTree = SDKROOT; }; DBFDCE4027F450FC00BE99E3 /* TwidereXIntent.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TwidereXIntent.appex; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -928,16 +878,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - DB7FF06E28853B1F00BFD55E /* AppShared.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - DB94B6B826C65CB000A2E8A1 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - DBB0E3C32760FB1200F1D45F /* TwidereSDK in Frameworks */, - E5E65DC85CA480AE17571EFC /* Pods_AppShared.framework in Frameworks */, + DB05E13B29C3185E0055BF3F /* TwidereSDK in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -945,7 +886,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - DBFC0AE6276118080011E99B /* AppShared.framework in Frameworks */, + DB05E13729C318530055BF3F /* TwidereSDK in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -953,14 +894,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - DB2611B7251B2D42004BF309 /* CryptoSwift in Frameworks */, - DBFC0AE9276118240011E99B /* AppShared.framework in Frameworks */, - DB6CF1C4269715CD001DE069 /* FPSIndicator in Frameworks */, - DBD98489251CB88A00ED87A1 /* Tabman in Frameworks */, - DBFADA892872BEDA00B512D6 /* CoverFlowStackCollectionViewLayout in Frameworks */, + DB05E13D29C321960055BF3F /* TwidereSDK in Frameworks */, DBFADA8B2872BEDE00B512D6 /* TabBarPager in Frameworks */, - DBEA4F842511F7460007FEC5 /* Kanna in Frameworks */, - DB7AB33E2744E3740035EB8A /* Floaty in Frameworks */, 44CCAE5E7B79E0C359E367E5 /* Pods_TwidereX.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -985,7 +920,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - DBFDCE5427F451FC00BE99E3 /* AppShared.framework in Frameworks */, + DB05E13929C318590055BF3F /* TwidereSDK in Frameworks */, DBFDCE4127F450FC00BE99E3 /* Intents.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1029,7 +964,6 @@ DB02C76E27350D75007EA0BF /* Hashtag */ = { isa = PBXGroup; children = ( - DB02C77327351B80007EA0BF /* Cell */, DB02C76C27350D71007EA0BF /* SearchHashtagViewController.swift */, DB02C76F27350D8A007EA0BF /* SearchHashtagViewModel.swift */, DB02C77827352217007EA0BF /* SearchHashtagViewModel+Diffable.swift */, @@ -1038,15 +972,6 @@ path = Hashtag; sourceTree = ""; }; - DB02C77327351B80007EA0BF /* Cell */ = { - isa = PBXGroup; - children = ( - DB02C77127351B7D007EA0BF /* HashtagTableViewCell.swift */, - DB02C77427351DA2007EA0BF /* HashtagTableViewCell+ViewModel.swift */, - ); - path = Cell; - sourceTree = ""; - }; DB0491FF25904CE400CCD50E /* Toolbar */ = { isa = PBXGroup; children = ( @@ -1055,14 +980,6 @@ path = Toolbar; sourceTree = ""; }; - DB04920325904CFA00CCD50E /* Container */ = { - isa = PBXGroup; - children = ( - DBF639FB259B333A009E12C8 /* EmptyStateView.swift */, - ); - path = Container; - sourceTree = ""; - }; DB04920A25904D4900CCD50E /* Content */ = { isa = PBXGroup; children = ( @@ -1121,6 +1038,8 @@ DB148B01281A729600B596C7 /* Sidebar */, DBDA8E9624FE075E006750DC /* MainTab */, DB66DB8E2823A7CC0071F5F3 /* SecondaryTab */, + DBEA97642A1B557100C8B75B /* SecondaryContainer */, + DBEA97692A1B6D2C00C8B75B /* NewColumn */, DB148B02281A7AB300B596C7 /* ContentSplitViewController.swift */, ); path = Root; @@ -1144,24 +1063,161 @@ path = View; sourceTree = ""; }; - DB1D7B5925B5A87600397DCD /* Option */ = { + DB1B80202A4030AA00C90A7D /* Diffable */ = { isa = PBXGroup; children = ( - DB1D7B4225B5938300397DCD /* TwitterAuthenticationOptionViewController.swift */, - DB1D7B5425B5A87200397DCD /* TwitterAuthenticationOptionViewModel.swift */, + DB1B80212A4030AA00C90A7D /* Misc */, + DB1B80432A403B0A00C90A7D /* User */, + DB1B803F2A4030AA00C90A7D /* Status */, ); - path = Option; + path = Diffable; + sourceTree = ""; + }; + DB1B80212A4030AA00C90A7D /* Misc */ = { + isa = PBXGroup; + children = ( + DB1B80222A4030AA00C90A7D /* Sidebar */, + DB1B80252A4030AA00C90A7D /* Hashtag */, + DB1B80292A4030AA00C90A7D /* Notification */, + DB1B802C2A4030AA00C90A7D /* TabBar */, + DB1B802E2A4030AA00C90A7D /* CoverFlowStack */, + DB1B80312A4030AA00C90A7D /* Search */, + DB1B80342A4030AA00C90A7D /* List */, + DB1B80372A4030AA00C90A7D /* History */, + DB1B803A2A4030AA00C90A7D /* Media */, + ); + path = Misc; + sourceTree = ""; + }; + DB1B80222A4030AA00C90A7D /* Sidebar */ = { + isa = PBXGroup; + children = ( + DB1B80232A4030AA00C90A7D /* SidebarSection.swift */, + DB1B80242A4030AA00C90A7D /* SidebarItem.swift */, + ); + path = Sidebar; + sourceTree = ""; + }; + DB1B80252A4030AA00C90A7D /* Hashtag */ = { + isa = PBXGroup; + children = ( + DB1B80272A4030AA00C90A7D /* HashtagSection.swift */, + DB1B80282A4030AA00C90A7D /* HashtagItem.swift */, + ); + path = Hashtag; + sourceTree = ""; + }; + DB1B80292A4030AA00C90A7D /* Notification */ = { + isa = PBXGroup; + children = ( + DB1B802A2A4030AA00C90A7D /* NotificationSection.swift */, + DB1B802B2A4030AA00C90A7D /* NotificationItem.swift */, + ); + path = Notification; + sourceTree = ""; + }; + DB1B802C2A4030AA00C90A7D /* TabBar */ = { + isa = PBXGroup; + children = ( + DB1B802D2A4030AA00C90A7D /* TabBarItem.swift */, + ); + path = TabBar; + sourceTree = ""; + }; + DB1B802E2A4030AA00C90A7D /* CoverFlowStack */ = { + isa = PBXGroup; + children = ( + DB1B802F2A4030AA00C90A7D /* CoverFlowStackSection.swift */, + DB1B80302A4030AA00C90A7D /* CoverFlowStackItem.swift */, + ); + path = CoverFlowStack; sourceTree = ""; }; - DB1E48172772CEC40074F6A0 /* Search */ = { + DB1B80312A4030AA00C90A7D /* Search */ = { isa = PBXGroup; children = ( - DB1E48152772CEC20074F6A0 /* SearchSection.swift */, - DB1E48182772CECC0074F6A0 /* SearchItem.swift */, + DB1B80322A4030AA00C90A7D /* SearchSection.swift */, + DB1B80332A4030AA00C90A7D /* SearchItem.swift */, ); path = Search; sourceTree = ""; }; + DB1B80342A4030AA00C90A7D /* List */ = { + isa = PBXGroup; + children = ( + DB1B80352A4030AA00C90A7D /* ListItem.swift */, + DB1B80362A4030AA00C90A7D /* ListSection.swift */, + ); + path = List; + sourceTree = ""; + }; + DB1B80372A4030AA00C90A7D /* History */ = { + isa = PBXGroup; + children = ( + DB1B80382A4030AA00C90A7D /* HistorySection.swift */, + DB1B80392A4030AA00C90A7D /* HistoryItem.swift */, + ); + path = History; + sourceTree = ""; + }; + DB1B803A2A4030AA00C90A7D /* Media */ = { + isa = PBXGroup; + children = ( + DB1B803B2A4030AA00C90A7D /* MediaSection.swift */, + ); + path = Media; + sourceTree = ""; + }; + DB1B803F2A4030AA00C90A7D /* Status */ = { + isa = PBXGroup; + children = ( + DB1B80402A4030AA00C90A7D /* StatusMediaGallerySection.swift */, + DB1B80412A4030AA00C90A7D /* StatusItem.swift */, + DB1B80422A4030AA00C90A7D /* StatusSection.swift */, + ); + path = Status; + sourceTree = ""; + }; + DB1B80432A403B0A00C90A7D /* User */ = { + isa = PBXGroup; + children = ( + DB1B80442A403B0A00C90A7D /* UserItem.swift */, + DB1B80452A403B0A00C90A7D /* UserSection.swift */, + ); + path = User; + sourceTree = ""; + }; + DB1D3DEB289388CF008F0BD0 /* History */ = { + isa = PBXGroup; + children = ( + DB1D3DF028938CD4008F0BD0 /* Status */, + DBFCEF1328939C9900EEBFB1 /* User */, + DB1D3DE9289388CD008F0BD0 /* HistoryViewController.swift */, + DB1D3DEC28938ACD008F0BD0 /* HistoryViewModel.swift */, + ); + path = History; + sourceTree = ""; + }; + DB1D3DF028938CD4008F0BD0 /* Status */ = { + isa = PBXGroup; + children = ( + DB1D3DEE28938CD1008F0BD0 /* StatusHistoryViewController.swift */, + DBFCEF182893BA8A00EEBFB1 /* StatusHistoryViewController+DataSourceProvider.swift */, + DB1D3DF128938CDF008F0BD0 /* StatusHistoryViewModel.swift */, + DBFCEF162893B92B00EEBFB1 /* StatusHistoryViewModel+Diffable.swift */, + ); + path = Status; + sourceTree = ""; + }; + DB1D7B5925B5A87600397DCD /* Option */ = { + isa = PBXGroup; + children = ( + DB1D7B4225B5938300397DCD /* TwitterAuthenticationOptionViewController.swift */, + DB1D7B5425B5A87200397DCD /* TwitterAuthenticationOptionViewModel.swift */, + ); + path = Option; + sourceTree = ""; + }; DB25C4C62779949F00EC1435 /* Cell */ = { isa = PBXGroup; children = ( @@ -1216,8 +1272,9 @@ children = ( DB2C8739274F4B7D00CE0398 /* List */, DB580EAE288187BD00BC4A0F /* AccountPreference */, - DBF81C8127F6E84300004A56 /* AppearancePreference */, + DB88E62A2890DF6A009A01F5 /* BehaviorsPreference */, DB2C8733274F4B7D00CE0398 /* DisplayPreference */, + DBF87AD32892A6540029A7C7 /* AppIconPreference */, DB2C8736274F4B7D00CE0398 /* About */, DB2C872F274F4B7D00CE0398 /* Developer */, DBE6357B28855546001C114B /* PushNotificationScratch */, @@ -1238,8 +1295,10 @@ DB2C8733274F4B7D00CE0398 /* DisplayPreference */ = { isa = PBXGroup; children = ( + DBF87AD62892A67D0029A7C7 /* Translation */, DB2C8734274F4B7D00CE0398 /* DisplayPreferenceViewController.swift */, DB2C8735274F4B7D00CE0398 /* DisplayPreferenceViewModel.swift */, + DB88E625288EA1F7009A01F5 /* DisplayPreferenceView.swift */, ); path = DisplayPreference; sourceTree = ""; @@ -1257,9 +1316,9 @@ DB2C8739274F4B7D00CE0398 /* List */ = { isa = PBXGroup; children = ( - DB2C873A274F4B7D00CE0398 /* SettingListView.swift */, - DB235EF32834DD0900398FCA /* SettingListViewModel.swift */, DB2C873B274F4B7D00CE0398 /* SettingListViewController.swift */, + DB235EF32834DD0900398FCA /* SettingListViewModel.swift */, + DB2C873A274F4B7D00CE0398 /* SettingListView.swift */, ); path = List; sourceTree = ""; @@ -1385,15 +1444,7 @@ DB46D11B27DB2703003B8BA1 /* ListUser */, DB2C0BDB27DF14970033FC94 /* EditList */, DBA6B30F27E9CB7D004D052D /* AddListMember */, - ); - path = List; - sourceTree = ""; - }; - DB47AB1E27CCC18500CD73C7 /* List */ = { - isa = PBXGroup; - children = ( - DB47AB2027CCC18500CD73C7 /* ListSection.swift */, - DB47AB1F27CCC18500CD73C7 /* ListItem.swift */, + DBFCA3F529F9185400B9DCA3 /* HomeList */, ); path = List; sourceTree = ""; @@ -1409,21 +1460,7 @@ path = List; sourceTree = ""; }; - DB56329826DCBE1900FC893F /* StatusThread */ = { - isa = PBXGroup; - children = ( - DB5632BD26DF503D00FC893F /* Twitter */, - DB01091626E5EB67005F67D7 /* Mastodon */, - DB56329626DCBE1600FC893F /* StatusThreadViewController.swift */, - DB3B905E26E8A9650010F64C /* StatusThreadViewController+DataSourceProvider.swift */, - DB56329926DCBE7300FC893F /* StatusThreadViewModel.swift */, - DB5632AC26DCE93900FC893F /* StatusThreadViewModel+Diffable.swift */, - DB5632B326DDE3F300FC893F /* StatusThreadViewModel+LoadThreadState.swift */, - ); - path = StatusThread; - sourceTree = ""; - }; - DB5632A526DCC82D00FC893F /* Provider */ = { + DB55496129E01338004AF42A /* Facade */ = { isa = PBXGroup; children = ( DB5632AA26DCCD3900FC893F /* DataSourceFacade.swift */, @@ -1431,6 +1468,7 @@ DB76A661275F65FE00A50673 /* DataSourceFacade+Model.swift */, DB3B906A26E8D3AB0010F64C /* DataSourceFacade+Status.swift */, DB01091F26E60756005F67D7 /* DataSourceFacade+StatusThread.swift */, + DBFCEF1F2893E18400EEBFB1 /* DataSourceFacade+History.swift */, DB262A382721621800D18EF3 /* DataSourceFacade+User.swift */, DB25C4C427798E1A00EC1435 /* DataSourceFacade+SavedSearch.swift */, DB01092126E608B7005F67D7 /* DataSourceFacade+Repost.swift */, @@ -1446,8 +1484,30 @@ DB01A9A327637FE60055FABC /* DataSourceFacade+Meta.swift */, DB581A0027D89A3700C35B91 /* DataSourceFacade+LIst.swift */, DBF81C7B27F6A93E00004A56 /* DataSourceFacade+Translate.swift */, + ); + path = Facade; + sourceTree = ""; + }; + DB56329826DCBE1900FC893F /* StatusThread */ = { + isa = PBXGroup; + children = ( + DB5632BD26DF503D00FC893F /* Twitter */, + DB01091626E5EB67005F67D7 /* Mastodon */, + DB56329626DCBE1600FC893F /* StatusThreadViewController.swift */, + DB3B905E26E8A9650010F64C /* StatusThreadViewController+DataSourceProvider.swift */, + DB56329926DCBE7300FC893F /* StatusThreadViewModel.swift */, + DB5632AC26DCE93900FC893F /* StatusThreadViewModel+Diffable.swift */, + DB5632B326DDE3F300FC893F /* StatusThreadViewModel+LoadThreadState.swift */, + ); + path = StatusThread; + sourceTree = ""; + }; + DB5632A526DCC82D00FC893F /* Provider */ = { + isa = PBXGroup; + children = ( DB56329B26DCC23700FC893F /* DataSourceProvider.swift */, DB5632A626DCC84C00FC893F /* DataSourceProvider+UITableViewDelegate.swift */, + DB55495F29E01330004AF42A /* DataSourceProvider+UITableViewDataSourcePrefetching.swift */, DB01092326E6199C005F67D7 /* DataSourceProvider+StatusViewTableViewCellDelegate.swift */, DB01739B276B56C100AB844C /* DataSourceProvider+UserViewTableViewCellDelegate.swift */, DB76A65E275F587300A50673 /* DataSourceProvider+MediaInfoDescriptionViewDelegate.swift */, @@ -1511,14 +1571,6 @@ path = SecondaryTab; sourceTree = ""; }; - DB66DB912823AC400071F5F3 /* TabBar */ = { - isa = PBXGroup; - children = ( - DB66DB8F2823AC3C0071F5F3 /* TabBarItem.swift */, - ); - path = TabBar; - sourceTree = ""; - }; DB676105254AD4B1006C6798 /* CollectionViewCell */ = { isa = PBXGroup; children = ( @@ -1590,6 +1642,15 @@ path = Trend; sourceTree = ""; }; + DB6CD2AA29D1F2AD003AE784 /* TestPlan */ = { + isa = PBXGroup; + children = ( + DB6B8B2C25AF235F00F20FD5 /* TwidereX.xctestplan */, + DB6B8B2D25AF235F00F20FD5 /* TwidereX-Performance.xctestplan */, + ); + path = TestPlan; + sourceTree = ""; + }; DB7274F0273BB25A00577D95 /* Notification */ = { isa = PBXGroup; children = ( @@ -1611,7 +1672,7 @@ DB6DF3DF252060AA00E8A273 /* ProfileViewModel.swift */, DB3B906826E8D1D80010F64C /* LocalProfileViewModel.swift */, DB76A67027609A8700A50673 /* RemoteProfileViewModel.swift */, - DB34029C2521BE8B009EFADF /* MeProfileViewModel.swift */, + DB58F1EB298BD07400836FBE /* MeProfileViewModel.swift */, ); path = Profile; sourceTree = ""; @@ -1671,86 +1732,6 @@ path = NotificationTimeline; sourceTree = ""; }; - DB8761A22745515F00BA7EE2 /* Diffable */ = { - isa = PBXGroup; - children = ( - DB8761A32745515F00BA7EE2 /* User */, - DB8761A92745515F00BA7EE2 /* Status */, - DB8761AF2745515F00BA7EE2 /* Misc */, - ); - path = Diffable; - sourceTree = ""; - }; - DB8761A32745515F00BA7EE2 /* User */ = { - isa = PBXGroup; - children = ( - DB8761A42745515F00BA7EE2 /* UserItem.swift */, - DB8761A82745515F00BA7EE2 /* UserSection.swift */, - ); - path = User; - sourceTree = ""; - }; - DB8761A92745515F00BA7EE2 /* Status */ = { - isa = PBXGroup; - children = ( - DB8761AA2745515F00BA7EE2 /* StatusMediaGallerySection.swift */, - DB8761AD2745515F00BA7EE2 /* StatusItem.swift */, - DB8761AE2745515F00BA7EE2 /* StatusSection.swift */, - ); - path = Status; - sourceTree = ""; - }; - DB8761AF2745515F00BA7EE2 /* Misc */ = { - isa = PBXGroup; - children = ( - DB8761B02745515F00BA7EE2 /* Hashtag */, - DB8761B42745515F00BA7EE2 /* Notification */, - DB8761BA2745515F00BA7EE2 /* CoverFlowStack */, - DB8761D12745553A00BA7EE2 /* Media */, - DBD0B49E2758B5F70015A388 /* Sidebar */, - DB1E48172772CEC40074F6A0 /* Search */, - DB47AB1E27CCC18500CD73C7 /* List */, - DB66DB912823AC400071F5F3 /* TabBar */, - ); - path = Misc; - sourceTree = ""; - }; - DB8761B02745515F00BA7EE2 /* Hashtag */ = { - isa = PBXGroup; - children = ( - DB8761B12745515F00BA7EE2 /* HashtagData.swift */, - DB8761B22745515F00BA7EE2 /* HashtagSection.swift */, - DB8761B32745515F00BA7EE2 /* HashtagItem.swift */, - ); - path = Hashtag; - sourceTree = ""; - }; - DB8761B42745515F00BA7EE2 /* Notification */ = { - isa = PBXGroup; - children = ( - DB8761B52745515F00BA7EE2 /* NotificationSection.swift */, - DB8761B72745515F00BA7EE2 /* NotificationItem.swift */, - ); - path = Notification; - sourceTree = ""; - }; - DB8761BA2745515F00BA7EE2 /* CoverFlowStack */ = { - isa = PBXGroup; - children = ( - DB8761BB2745515F00BA7EE2 /* CoverFlowStackSection.swift */, - DB8761BC2745515F00BA7EE2 /* CoverFlowStackItem.swift */, - ); - path = CoverFlowStack; - sourceTree = ""; - }; - DB8761D12745553A00BA7EE2 /* Media */ = { - isa = PBXGroup; - children = ( - DB8761CF2745553600BA7EE2 /* MediaSection.swift */, - ); - path = Media; - sourceTree = ""; - }; DB88AC34250B25170009E562 /* Vender */ = { isa = PBXGroup; children = ( @@ -1762,6 +1743,16 @@ path = Vender; sourceTree = ""; }; + DB88E62A2890DF6A009A01F5 /* BehaviorsPreference */ = { + isa = PBXGroup; + children = ( + DB88E6282890DF67009A01F5 /* BehaviorsPreferenceViewController.swift */, + DB88E62B2890DF79009A01F5 /* BehaviorsPreferenceViewModel.swift */, + DB88E62D2890DF7E009A01F5 /* BehaviorsPreferenceView.swift */, + ); + path = BehaviorsPreference; + sourceTree = ""; + }; DB8AC1122540501A00E636BE /* Compose */ = { isa = PBXGroup; children = ( @@ -1819,27 +1810,9 @@ path = Mastodon; sourceTree = ""; }; - DB94B6BC26C65CB000A2E8A1 /* AppShared */ = { - isa = PBXGroup; - children = ( - DBCDCE602760B8B000F0B78C /* Info.plist */, - DB94B6BD26C65CB000A2E8A1 /* AppShared.h */, - DBCC7AE7274BA10300E0986D /* Vender */, - ); - path = AppShared; - sourceTree = ""; - }; DB97D1E7256CBA5D0056F8C2 /* TableViewCell */ = { isa = PBXGroup; children = ( - DB89F590257A2A70005ECD04 /* TimelineLoaderTableViewCell.swift */, - DB47AB3D27CE1F9E00CD73C7 /* TimelineTopLoaderTableViewCell.swift */, - DB98DC0D2507862D0087E30F /* TimelineMiddleLoaderTableViewCell.swift */, - DBC17A5B26E0A4C1005C0D79 /* TimelineMiddleLoaderTableViewCell+ViewModel.swift */, - DB98DC0F250787420087E30F /* TimelineBottomLoaderTableViewCell.swift */, - DB3B906026E8AB480010F64C /* StatusViewTableViewCellDelegate.swift */, - DB30ADDB26CFC7EE00B2D2BE /* StatusTableViewCell.swift */, - DB5FD9B226D8D94600CF5439 /* StatusTableViewCell+ViewModel.swift */, DB5632AF26DCED1300FC893F /* StatusThreadRootTableViewCell.swift */, DB5632B126DCF1CD00FC893F /* StatusThreadRootTableViewCell+ViewModel.swift */, DB25C4C7277994B800EC1435 /* CenterFootnoteLabelTableViewCell.swift */, @@ -2022,15 +1995,6 @@ path = Activity; sourceTree = ""; }; - DBCC7AE7274BA10300E0986D /* Vender */ = { - isa = PBXGroup; - children = ( - DB01091B26E606BB005F67D7 /* OfficialTwitterTextProvider.swift */, - DBCC7AE3274B95C900E0986D /* DateTimeSwiftProvider.swift */, - ); - path = Vender; - sourceTree = ""; - }; DBCE2EAD2591F37400926D09 /* FollowingList */ = { isa = PBXGroup; children = ( @@ -2040,15 +2004,6 @@ path = FollowingList; sourceTree = ""; }; - DBD0B49E2758B5F70015A388 /* Sidebar */ = { - isa = PBXGroup; - children = ( - DBD0B49C2758B5F50015A388 /* SidebarSection.swift */, - DBD0B49F2758B6010015A388 /* SidebarItem.swift */, - ); - path = Sidebar; - sourceTree = ""; - }; DBD40BF62599CB93006E4ABC /* FollowerList */ = { isa = PBXGroup; children = ( @@ -2073,14 +2028,12 @@ DBDA8E1524FCF8A3006750DC = { isa = PBXGroup; children = ( - DB33E5B42719637400EC2225 /* CoverFlowStackCollectionViewLayout */, + DBE399D829EFE3A7008FA278 /* CoverFlowStackLayout */, DB51DC372716AEF000A0D8FB /* TabBarPager */, DB43239D251491EB004FAAEC /* TwidereSDK */, DBDA8E2024FCF8A3006750DC /* TwidereX */, DBDA8E3724FCF8A7006750DC /* TwidereXTests */, DBDA8E4224FCF8A7006750DC /* TwidereXUITests */, - DBE76CE92500B29300DEB0FC /* StubMixer */, - DB94B6BC26C65CB000A2E8A1 /* AppShared */, DBBBBE5F2744E8CC007ACB4B /* ShareExtension */, DBFDCE4227F450FC00BE99E3 /* TwidereXIntent */, DB7FF05F28853A7F00BFD55E /* NotificationService */, @@ -2096,7 +2049,6 @@ DBDA8E1E24FCF8A3006750DC /* TwidereX.app */, DBDA8E3424FCF8A7006750DC /* TwidereXTests.xctest */, DBDA8E3F24FCF8A7006750DC /* TwidereXUITests.xctest */, - DB94B6BB26C65CB000A2E8A1 /* AppShared.framework */, DBBBBE5E2744E8CC007ACB4B /* ShareExtension.appex */, DBFDCE4027F450FC00BE99E3 /* TwidereXIntent.appex */, DB7FF05E28853A7F00BFD55E /* NotificationService.appex */, @@ -2109,18 +2061,17 @@ children = ( DBDA8E2F24FCF8A6006750DC /* Info.plist */, DBE76CD2250095D900DEB0FC /* TwidereX.entitlements */, - DB6B8B2C25AF235F00F20FD5 /* TwidereX.xctestplan */, - DB6B8B2D25AF235F00F20FD5 /* TwidereX-Performance.xctestplan */, DBA122CA256C13B000928671 /* GoogleService-Info.plist */, DBCB408E255D8C2E00DD8D8F /* Activity */, DB2C8710274F4B1C00CE0398 /* Coordinator */, - DB8761A22745515F00BA7EE2 /* Diffable */, + DB1B80202A4030AA00C90A7D /* Diffable */, DB88AC34250B25170009E562 /* Vender */, DBDA8E5424FDF3E2006750DC /* Scene */, DBDA8E9924FE0DC8006750DC /* Resources */, DBF81C8B27F843D700004A56 /* Generated */, DBE76D312502147200DEB0FC /* Extension */, DBED96DC253F5D7B00C5383A /* Protocol */, + DB6CD2AA29D1F2AD003AE784 /* TestPlan */, DBDA8E5324FDF3D6006750DC /* Supporting Files */, ); path = TwidereX; @@ -2143,6 +2094,7 @@ DBDA8E4524FCF8A7006750DC /* Info.plist */, DBDA8E4324FCF8A7006750DC /* TwidereXUITests.swift */, DBDA7F1927D1CBCA00BA6BE1 /* TwidereXUITests+Onboarding.swift */, + DB8BFB1B29D1F52900535092 /* TwidereXUITests+Performance.swift */, ); path = TwidereXUITests; sourceTree = ""; @@ -2169,14 +2121,14 @@ DB42411026C3E4C900B6C5F8 /* Onboarding */, DBCB4032255B670F00DD8D8F /* Account */, DB697DEB278FDB8A004EF2F7 /* Timeline */, - DB47AB1B27CCB7F100CD73C7 /* List */, - DBE71B7B26B7AF6100DFAB8E /* StubTimeline */, DB56329826DCBE1900FC893F /* StatusThread */, DB7274F0273BB25A00577D95 /* Notification */, DB747FFD251C496E000C4BD7 /* Profile */, DB8AC1122540501A00E636BE /* Compose */, DBF69E932549601C00E2A915 /* Search */, DBA5F9E42553CFEB00D2E98E /* MediaPreview */, + DB47AB1B27CCB7F100CD73C7 /* List */, + DB1D3DEB289388CF008F0BD0 /* History */, DB2C872E274F4B7D00CE0398 /* Setting */, ); path = Scene; @@ -2221,31 +2173,6 @@ path = PushNotificationScratch; sourceTree = ""; }; - DBE71B7B26B7AF6100DFAB8E /* StubTimeline */ = { - isa = PBXGroup; - children = ( - DBE71B7926B7AF5C00DFAB8E /* StubTimelineViewController.swift */, - DBE71B7C26B7C5FD00DFAB8E /* StubTimelineViewModel.swift */, - DBE71B7E26B7D68500DFAB8E /* StubTimelineCollectionViewCell.swift */, - ); - path = StubTimeline; - sourceTree = ""; - }; - DBE76CE92500B29300DEB0FC /* StubMixer */ = { - isa = PBXGroup; - children = ( - DBE76CEA2500B29300DEB0FC /* AppDelegate.swift */, - DBE76CEC2500B29300DEB0FC /* SceneDelegate.swift */, - DBE76CEE2500B29300DEB0FC /* ViewController.swift */, - DBE76CFE2500B43300DEB0FC /* StubMixer.swift */, - DBE76CF02500B29300DEB0FC /* Main.storyboard */, - DBE76CF32500B29500DEB0FC /* Assets.xcassets */, - DBE76CF52500B29500DEB0FC /* LaunchScreen.storyboard */, - DBE76CF82500B29500DEB0FC /* Info.plist */, - ); - path = StubMixer; - sourceTree = ""; - }; DBE76D312502147200DEB0FC /* Extension */ = { isa = PBXGroup; children = ( @@ -2281,16 +2208,35 @@ DBF63A08259B4818009E12C8 /* CollectionView */, DB676105254AD4B1006C6798 /* CollectionViewCell */, DB04920A25904D4900CCD50E /* Content */, - DB04920325904CFA00CCD50E /* Container */, DB0491FF25904CE400CCD50E /* Toolbar */, DB97D1EB256CBA6F0056F8C2 /* Button */, ); path = View; sourceTree = ""; }; + DBEA97642A1B557100C8B75B /* SecondaryContainer */ = { + isa = PBXGroup; + children = ( + DBEA97622A1B556C00C8B75B /* SecondaryContainerViewController.swift */, + DBEA97652A1B58E200C8B75B /* SecondaryContainerViewModel.swift */, + ); + path = SecondaryContainer; + sourceTree = ""; + }; + DBEA97692A1B6D2C00C8B75B /* NewColumn */ = { + isa = PBXGroup; + children = ( + DBEA97672A1B6D2300C8B75B /* NewColumnViewController.swift */, + DB0455B52A1CA2F3009A00EF /* NewColumnViewModel.swift */, + DB0455B72A1CA510009A00EF /* NewColumnView.swift */, + ); + path = NewColumn; + sourceTree = ""; + }; DBED96DC253F5D7B00C5383A /* Protocol */ = { isa = PBXGroup; children = ( + DB55496129E01338004AF42A /* Facade */, DB5632A526DCC82D00FC893F /* Provider */, DBF167FC27C394830001F75E /* NeedsDependency+AvatarBarButtonItemDelegate.swift */, DBED96D7253F5D7800C5383A /* NamingState.swift */, @@ -2367,51 +2313,58 @@ path = SearchDetail; sourceTree = ""; }; - DBF81C8127F6E84300004A56 /* AppearancePreference */ = { + DBF81C8B27F843D700004A56 /* Generated */ = { isa = PBXGroup; children = ( - DBF81C9227F8449800004A56 /* AppIcon */, - DBF81C8627F709F200004A56 /* Translation */, - DBF81C7D27F6E7F500004A56 /* AppearancePreferenceViewController.swift */, - DBF81C8227F6ECF400004A56 /* AppearancePreferenceViewModel.swift */, - DBF81C7F27F6E80700004A56 /* AppearancePreferenceView.swift */, + DBF81C8C27F843D700004A56 /* AppIconAssets.swift */, ); - path = AppearancePreference; + path = Generated; sourceTree = ""; }; - DBF81C8627F709F200004A56 /* Translation */ = { + DBF87AD32892A6540029A7C7 /* AppIconPreference */ = { isa = PBXGroup; children = ( - DBF81C8427F709EF00004A56 /* TranslateButtonPreferenceView.swift */, - DBF81C8727F7141600004A56 /* TranslationServicePreferenceView.swift */, + DBF87AD42892A6740029A7C7 /* AppIconPreferenceView.swift */, ); - name = Translation; + path = AppIconPreference; sourceTree = ""; }; - DBF81C8B27F843D700004A56 /* Generated */ = { + DBF87AD62892A67D0029A7C7 /* Translation */ = { isa = PBXGroup; children = ( - DBF81C8C27F843D700004A56 /* AppIconAssets.swift */, + DBF87AD72892A67D0029A7C7 /* TranslateButtonPreferenceView.swift */, + DBF87AD82892A67D0029A7C7 /* TranslationServicePreferenceView.swift */, ); - path = Generated; + path = Translation; + sourceTree = ""; + }; + DBFA471D2859C33900C9FF7F /* Like */ = { + isa = PBXGroup; + children = ( + DBFA471E2859C4AE00C9FF7F /* UserLikeTimelineViewController.swift */, + DBFA47202859C4F300C9FF7F /* UserLikeTimelineViewModel.swift */, + ); + path = Like; sourceTree = ""; }; - DBF81C9227F8449800004A56 /* AppIcon */ = { + DBFCA3F529F9185400B9DCA3 /* HomeList */ = { isa = PBXGroup; children = ( - DBF81C9027F8448C00004A56 /* AppIconPreferenceView.swift */, + DBFCA3F329F9185100B9DCA3 /* HomeListStatusTimelineViewController.swift */, + DBFCA3F629F9191F00B9DCA3 /* HostListStatusTimelineViewModel.swift */, ); - name = AppIcon; + path = HomeList; sourceTree = ""; }; - DBFA471D2859C33900C9FF7F /* Like */ = { + DBFCEF1328939C9900EEBFB1 /* User */ = { isa = PBXGroup; children = ( - DBFA471E2859C4AE00C9FF7F /* UserLikeTimelineViewController.swift */, - DBFA47202859C4F300C9FF7F /* UserLikeTimelineViewModel.swift */, - DBFA471B2859C33500C9FF7F /* MeLikeTimelineViewModel.swift */, + DBFCEF1128939C9600EEBFB1 /* UserHistoryViewController.swift */, + DB92DB3E2898026B0011B564 /* UserHistoryViewController+DataSourceProvider.swift */, + DBFCEF1428939CAC00EEBFB1 /* UserHistoryViewModel.swift */, + DB92DB40289804440011B564 /* UserHistoryViewModel+Diffable.swift */, ); - path = Like; + path = User; sourceTree = ""; }; DBFDCE4227F450FC00BE99E3 /* TwidereXIntent */ = { @@ -2447,17 +2400,6 @@ }; /* End PBXGroup section */ -/* Begin PBXHeadersBuildPhase section */ - DB94B6B626C65CB000A2E8A1 /* Headers */ = { - isa = PBXHeadersBuildPhase; - buildActionMask = 2147483647; - files = ( - DB94B6BE26C65CB000A2E8A1 /* AppShared.h in Headers */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXHeadersBuildPhase section */ - /* Begin PBXNativeTarget section */ DB7FF05D28853A7F00BFD55E /* NotificationService */ = { isa = PBXNativeTarget; @@ -2470,36 +2412,15 @@ buildRules = ( ); dependencies = ( - DB7FF07128853B1F00BFD55E /* PBXTargetDependency */, ); name = NotificationService; + packageProductDependencies = ( + DB05E13A29C3185E0055BF3F /* TwidereSDK */, + ); productName = NotificationService; productReference = DB7FF05E28853A7F00BFD55E /* NotificationService.appex */; productType = "com.apple.product-type.app-extension"; }; - DB94B6BA26C65CB000A2E8A1 /* AppShared */ = { - isa = PBXNativeTarget; - buildConfigurationList = DB94B6C326C65CB100A2E8A1 /* Build configuration list for PBXNativeTarget "AppShared" */; - buildPhases = ( - 51870AB6AC62D0F5C707B21E /* [CP] Check Pods Manifest.lock */, - DB94B6B626C65CB000A2E8A1 /* Headers */, - DB94B6B726C65CB000A2E8A1 /* Sources */, - DB94B6B826C65CB000A2E8A1 /* Frameworks */, - DB94B6B926C65CB000A2E8A1 /* Resources */, - DB0B3B8D26C6637500501BB7 /* Embed Frameworks */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = AppShared; - packageProductDependencies = ( - DBB0E3C22760FB1200F1D45F /* TwidereSDK */, - ); - productName = AppShared; - productReference = DB94B6BB26C65CB000A2E8A1 /* AppShared.framework */; - productType = "com.apple.product-type.framework"; - }; DBBBBE5D2744E8CC007ACB4B /* ShareExtension */ = { isa = PBXNativeTarget; buildConfigurationList = DBBBBE692744E8CC007ACB4B /* Build configuration list for PBXNativeTarget "ShareExtension" */; @@ -2511,11 +2432,13 @@ buildRules = ( ); dependencies = ( - DBBBBE742744EC27007ACB4B /* PBXTargetDependency */, DBBBBE822744F39C007ACB4B /* PBXTargetDependency */, DBBBBE892744F535007ACB4B /* PBXTargetDependency */, ); name = ShareExtension; + packageProductDependencies = ( + DB05E13629C318530055BF3F /* TwidereSDK */, + ); productName = ShareExtension; productReference = DBBBBE5E2744E8CC007ACB4B /* ShareExtension.appex */; productType = "com.apple.product-type.app-extension"; @@ -2539,20 +2462,14 @@ buildRules = ( ); dependencies = ( - DB94B6C026C65CB000A2E8A1 /* PBXTargetDependency */, DBFDCE4727F450FC00BE99E3 /* PBXTargetDependency */, DB77FF812847808C00182A0B /* PBXTargetDependency */, DB7FF06428853A7F00BFD55E /* PBXTargetDependency */, ); name = TwidereX; packageProductDependencies = ( - DBEA4F832511F7460007FEC5 /* Kanna */, - DB2611B6251B2D42004BF309 /* CryptoSwift */, - DBD98488251CB88A00ED87A1 /* Tabman */, - DB6CF1C3269715CD001DE069 /* FPSIndicator */, - DB7AB33D2744E3740035EB8A /* Floaty */, - DBFADA882872BEDA00B512D6 /* CoverFlowStackCollectionViewLayout */, DBFADA8A2872BEDE00B512D6 /* TabBarPager */, + DB05E13C29C321960055BF3F /* TwidereSDK */, ); productName = TwidereX; productReference = DBDA8E1E24FCF8A3006750DC /* TwidereX.app */; @@ -2590,6 +2507,7 @@ buildRules = ( ); dependencies = ( + DB8BFB1E29D1FC6200535092 /* PBXTargetDependency */, DBDA8E4124FCF8A7006750DC /* PBXTargetDependency */, ); name = TwidereXUITests; @@ -2608,9 +2526,11 @@ buildRules = ( ); dependencies = ( - DBFDCE5727F451FC00BE99E3 /* PBXTargetDependency */, ); name = TwidereXIntent; + packageProductDependencies = ( + DB05E13829C318590055BF3F /* TwidereSDK */, + ); productName = TwidereXIntent; productReference = DBFDCE4027F450FC00BE99E3 /* TwidereXIntent.appex */; productType = "com.apple.product-type.app-extension"; @@ -2628,10 +2548,6 @@ DB7FF05D28853A7F00BFD55E = { CreatedOnToolsVersion = 13.4.1; }; - DB94B6BA26C65CB000A2E8A1 = { - CreatedOnToolsVersion = 13.0; - LastSwiftMigration = 1300; - }; DBBBBE5D2744E8CC007ACB4B = { CreatedOnToolsVersion = 13.1; }; @@ -2673,11 +2589,6 @@ ); mainGroup = DBDA8E1524FCF8A3006750DC; packageReferences = ( - DBEA4F822511F7460007FEC5 /* XCRemoteSwiftPackageReference "Kanna" */, - DB2611B5251B2D42004BF309 /* XCRemoteSwiftPackageReference "CryptoSwift" */, - DBD98487251CB88A00ED87A1 /* XCRemoteSwiftPackageReference "Tabman" */, - DB6CF1C2269715CD001DE069 /* XCRemoteSwiftPackageReference "FPSIndicator" */, - DB7AB33C2744E3740035EB8A /* XCRemoteSwiftPackageReference "Floaty" */, ); productRefGroup = DBDA8E1F24FCF8A3006750DC /* Products */; projectDirPath = ""; @@ -2686,7 +2597,6 @@ DBDA8E1D24FCF8A3006750DC /* TwidereX */, DBDA8E3324FCF8A7006750DC /* TwidereXTests */, DBDA8E3E24FCF8A7006750DC /* TwidereXUITests */, - DB94B6BA26C65CB000A2E8A1 /* AppShared */, DBBBBE5D2744E8CC007ACB4B /* ShareExtension */, DBFDCE3F27F450FC00BE99E3 /* TwidereXIntent */, DB7FF05D28853A7F00BFD55E /* NotificationService */, @@ -2702,13 +2612,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - DB94B6B926C65CB000A2E8A1 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; DBBBBE5C2744E8CC007ACB4B /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -2797,28 +2700,6 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-TwidereX-TwidereXUITests/Pods-TwidereX-TwidereXUITests-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; - 51870AB6AC62D0F5C707B21E /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-AppShared-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; 6B1901F5A04D5AA62D66599E /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -2962,15 +2843,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - DB94B6B726C65CB000A2E8A1 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - DBCC7AE5274BA10100E0986D /* DateTimeSwiftProvider.swift in Sources */, - DBCC7AE6274BA10100E0986D /* OfficialTwitterTextProvider.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; DBBBBE5A2744E8CC007ACB4B /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -2984,15 +2856,18 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + DBBA21252A403BE100CEBCF8 /* StatusSection.swift in Sources */, + DBF87ADA2892A67D0029A7C7 /* TranslationServicePreferenceView.swift in Sources */, DB2D36F627D5E73A00C1FBE0 /* CompositeListViewController.swift in Sources */, DB5FD9B726D8EFF700CF5439 /* UserBriefInfoView+ViewModel.swift in Sources */, - DB8761BE274552F800BA7EE2 /* StatusMediaGallerySection.swift in Sources */, DB3B906926E8D1D80010F64C /* LocalProfileViewModel.swift in Sources */, DB8E4FD92563CD6900459DA2 /* TwitterPinBasedAuthenticationViewController.swift in Sources */, DB7274F2273BB29600577D95 /* NotificationViewModel.swift in Sources */, DB5FB0112727FD02006520FA /* SearchUserViewModel+Diffable.swift in Sources */, DBA7DD74256B96450008A95A /* UIFont.swift in Sources */, + DB1D3DF228938CDF008F0BD0 /* StatusHistoryViewModel.swift in Sources */, DB76A652275F1C3200A50673 /* MediaPreviewTransitionItem.swift in Sources */, + DBEA97632A1B556C00C8B75B /* SecondaryContainerViewController.swift in Sources */, DB697DF3278FDDF7004EF2F7 /* TimelineViewModel.swift in Sources */, DB235EF42834DD0900398FCA /* SettingListViewModel.swift in Sources */, DB83020D273D160400BF5224 /* NotificationTimelineViewModel+LoadOldestState.swift in Sources */, @@ -3004,16 +2879,15 @@ DB8302142742180C00BF5224 /* NotificationTimelineViewController+DebugAction.swift in Sources */, DB71C7D3271EB71800BE3819 /* ProfileViewController+DataSourceProvider.swift in Sources */, DB8301FA273CED2E00BF5224 /* NotificationTimelineViewModel.swift in Sources */, - DB34029D2521BE8B009EFADF /* MeProfileViewModel.swift in Sources */, DB36F35E257F74C10028F81E /* ScrollViewContainer.swift in Sources */, + DBEA97662A1B58E200C8B75B /* SecondaryContainerViewModel.swift in Sources */, DB76A65D275F52D500A50673 /* MediaInfoDescriptionView+ViewModel.swift in Sources */, - DB66DB902823AC3C0071F5F3 /* TabBarItem.swift in Sources */, DB697E0927904BFB004EF2F7 /* FederatedTimelineViewController.swift in Sources */, + DB1B80472A403B0A00C90A7D /* UserSection.swift in Sources */, DB76A664275F688D00A50673 /* DataSourceFacade+Share.swift in Sources */, DB761E4E255935380050DC01 /* DrawerSidebarHeaderView.swift in Sources */, DBCB402E255B670C00DD8D8F /* AccountListViewController.swift in Sources */, DBDA8E9824FE075E006750DC /* MainTabBarController.swift in Sources */, - DBF81C8827F7141600004A56 /* TranslationServicePreferenceView.swift in Sources */, DB76A66F276083CB00A50673 /* MediaPreviewTransitionViewController.swift in Sources */, DBCB408A255D8C2B00DD8D8F /* SafariActivity.swift in Sources */, DBD0B4992758B58F0015A388 /* DrawerSidebarAnimatedTransitioning.swift in Sources */, @@ -3028,8 +2902,8 @@ DB76A64F275F1C3200A50673 /* MediaPreviewingViewController.swift in Sources */, DB42411626C3EB9100B6C5F8 /* ReadabilityPadding.swift in Sources */, DB2C8740274F4B7D00CE0398 /* DisplayPreferenceViewModel.swift in Sources */, - DBFA471C2859C33500C9FF7F /* MeLikeTimelineViewModel.swift in Sources */, DBCE2E992591A44000926D09 /* UIViewAnimatingPosition.swift in Sources */, + DB88E626288EA1F7009A01F5 /* DisplayPreferenceView.swift in Sources */, DB262A422722970000D18EF3 /* SearchResultViewModel.swift in Sources */, DB9B3256285737FE00AC818D /* GridTimelineViewModel.swift in Sources */, DBCB4053255B6EB100DD8D8F /* AccountListViewModel.swift in Sources */, @@ -3037,40 +2911,36 @@ DB76A659275F498F00A50673 /* MediaPreviewImageView.swift in Sources */, DB580EB8288187BD00BC4A0F /* AccountPreferenceViewController.swift in Sources */, DB6BCD72277AEED600847054 /* TrendViewModel+Diffable.swift in Sources */, - DB98DC10250787420087E30F /* TimelineBottomLoaderTableViewCell.swift in Sources */, DB0AD4DF285872BE0002ABDB /* UserMediaTimelineViewController.swift in Sources */, DB25C4D32779ADD800EC1435 /* SearchResultContainerViewController.swift in Sources */, DB02C777273520B8007EA0BF /* SearchHashtagViewModel+State.swift in Sources */, DB580EB6288187BD00BC4A0F /* MastodonNotificationSectionView.swift in Sources */, + DBBA21242A403BCF00CEBCF8 /* MediaSection.swift in Sources */, DBDAF243274F530B00050319 /* SceneCoordinator.swift in Sources */, DB581A0127D89A3700C35B91 /* DataSourceFacade+LIst.swift in Sources */, + DBBA21262A403BE100CEBCF8 /* StatusItem.swift in Sources */, DB25C4CC27799FE600EC1435 /* SavedSearchViewController.swift in Sources */, DB0AD4F0285893FA0002ABDB /* TimelineViewModelDriver.swift in Sources */, DB830200273D04D000BF5224 /* NotificationTimelineViewModel+Diffable.swift in Sources */, DBCB4060255CAC0300DD8D8F /* TwitterAuthenticationController.swift in Sources */, DB5632B426DDE3F300FC893F /* StatusThreadViewModel+LoadThreadState.swift in Sources */, - DBD0B49D2758B5F50015A388 /* SidebarSection.swift in Sources */, - DBE71B7F26B7D68500DFAB8E /* StubTimelineCollectionViewCell.swift in Sources */, DB0AD4EB28587B520002ABDB /* FederatedTimelineViewModel+Diffable.swift in Sources */, DBF739CF275C247F00BF6AB5 /* DataSourceFacade+Mute.swift in Sources */, DB578CCD254C0BDB00745336 /* UserBriefInfoView.swift in Sources */, DBA21039275A0E77000B7CB2 /* FollowerListViewController.swift in Sources */, DB5632C226DF8DE100FC893F /* TimelineViewModel+LoadOldestState.swift in Sources */, DB3B906326E8BBD70010F64C /* ProfileHeaderView.swift in Sources */, - DB02C77227351B7D007EA0BF /* HashtagTableViewCell.swift in Sources */, DB3B906726E8CD6D0010F64C /* ProfileHeaderViewModel.swift in Sources */, DB51DC432718117900A0D8FB /* StatusMediaGalleryCollectionCell+ViewModel.swift in Sources */, DB44245F285A49CD0095AECF /* HashtagTimelineViewModel+Diffable.swift in Sources */, DB51DC412717FCAA00A0D8FB /* StatusMediaGalleryCollectionCell.swift in Sources */, DB9B3251285735F200AC818D /* GridTimelineViewController.swift in Sources */, - DB3B906126E8AB480010F64C /* StatusViewTableViewCellDelegate.swift in Sources */, DB46D11A27DB26FF003B8BA1 /* ListUserViewController.swift in Sources */, + DBBA21182A403BB600CEBCF8 /* HashtagItem.swift in Sources */, DB1E48122772CE380074F6A0 /* SearchViewModel.swift in Sources */, DB76A662275F65FF00A50673 /* DataSourceFacade+Model.swift in Sources */, DB2EBBF0255D368200956CAA /* TableViewEntryRow.swift in Sources */, DB580EB7288187BD00BC4A0F /* MastodonNotificationSectionViewModel.swift in Sources */, - DB30ADDC26CFC7EE00B2D2BE /* StatusTableViewCell.swift in Sources */, - DB8761CB2745530200BA7EE2 /* CoverFlowStackSection.swift in Sources */, DB8AC0F825401BA200E636BE /* UIViewController.swift in Sources */, DB434888251DFE2D005B599F /* ProfileBannerStatusView.swift in Sources */, DB442475285B17830095AECF /* SearchMediaTimelineViewModel+Diffable.swift in Sources */, @@ -3078,34 +2948,42 @@ DB761E62255942050050DC01 /* DrawerSidebarEntryView.swift in Sources */, DB92570A251C8FE0004FEFB5 /* ProfileHeaderViewController.swift in Sources */, DB47AB1D27CCB88000CD73C7 /* ListViewModel.swift in Sources */, - DB8761BF274552F800BA7EE2 /* StatusItem.swift in Sources */, - DB89F591257A2A70005ECD04 /* TimelineLoaderTableViewCell.swift in Sources */, + DBBA21212A403BC900CEBCF8 /* ListSection.swift in Sources */, + DBBA211E2A403BC500CEBCF8 /* SearchSection.swift in Sources */, DB747FF9251C496A000C4BD7 /* ProfileViewController.swift in Sources */, DB697E0C27904C56004EF2F7 /* FederatedTimelineViewModel.swift in Sources */, DB01A9A427637FE60055FABC /* DataSourceFacade+Meta.swift in Sources */, DB30ADDD26CFD3CC00B2D2BE /* HomeTimelineViewController+DebugAction.swift in Sources */, + DBFCEF192893BA8A00EEBFB1 /* StatusHistoryViewController+DataSourceProvider.swift in Sources */, DBA5FA192553DCBC00D2E98E /* TransitioningMath.swift in Sources */, DB56329C26DCC23700FC893F /* DataSourceProvider.swift in Sources */, DB44A56226C4FEAB004C8B78 /* WelcomeViewModel.swift in Sources */, + DB1D3DED28938ACD008F0BD0 /* HistoryViewModel.swift in Sources */, DB522F172886A08F0088017C /* UIStatusBarManager+HandleTapAction.m in Sources */, DBC8E04B2576337F00401E20 /* DisposeBagCollectable.swift in Sources */, + DB0455B62A1CA2F3009A00EF /* NewColumnViewModel.swift in Sources */, DBAA898E2758CF01001C273B /* DrawerSidebarHeaderView+ViewModel.swift in Sources */, + DBEA97682A1B6D2300C8B75B /* NewColumnViewController.swift in Sources */, DBADCDC82826658700D1CA4E /* MediaPreviewableViewController.swift in Sources */, DBF167FD27C394830001F75E /* NeedsDependency+AvatarBarButtonItemDelegate.swift in Sources */, DB02C77927352217007EA0BF /* SearchHashtagViewModel+Diffable.swift in Sources */, DB25C4CA277999BE00EC1435 /* ButtonTableViewCell.swift in Sources */, DB0AD4EA28587B040002ABDB /* TimelineViewController.swift in Sources */, + DB55496029E01330004AF42A /* DataSourceProvider+UITableViewDataSourcePrefetching.swift in Sources */, DBA6B30E27E9CB6F004D052D /* AddListMemberViewController.swift in Sources */, DBF63A38259D7CCB009E12C8 /* CornerSmoothPreviewViewController.swift in Sources */, + DBBA21202A403BC900CEBCF8 /* ListItem.swift in Sources */, DB0CC4BA27D5F7BA00A051B4 /* CompositeListViewModel+Diffable.swift in Sources */, - DB02C77527351DA2007EA0BF /* HashtagTableViewCell+ViewModel.swift in Sources */, + DBFCEF172893B92B00EEBFB1 /* StatusHistoryViewModel+Diffable.swift in Sources */, DB0AD4E22858734A0002ABDB /* UserMediaTimelineViewModel.swift in Sources */, DB522F1628869DAE0088017C /* Notification+Name+HandleTapAction.swift in Sources */, DB86433C26E898C5000C9879 /* DataSourceFacade+Like.swift in Sources */, DB6BCD70277AEAC700847054 /* TrendTableViewCell.swift in Sources */, DB442461285AD8530095AECF /* ListStatusTimelineViewController.swift in Sources */, + DBBA21232A403BCC00CEBCF8 /* HistorySection.swift in Sources */, DB914C4A26C3AC7C00E85F1A /* ContentOffsetFixedCollectionView.swift in Sources */, - DBD0B4A02758B6010015A388 /* SidebarItem.swift in Sources */, + DB1D3DEF28938CD1008F0BD0 /* StatusHistoryViewController.swift in Sources */, + DBFCEF1228939C9600EEBFB1 /* UserHistoryViewController.swift in Sources */, DB76A67127609A8700A50673 /* RemoteProfileViewModel.swift in Sources */, DB6BCD6B277ADBF300847054 /* TrendViewController.swift in Sources */, DB76A656275F354800A50673 /* MediaPreviewViewModel.swift in Sources */, @@ -3113,10 +2991,9 @@ DB580EB9288187BD00BC4A0F /* AccountPreferenceViewModel.swift in Sources */, DB33A4A925A319A0003CED7D /* ActionToolbarContainer.swift in Sources */, DB2C873C274F4B7D00CE0398 /* DeveloperView.swift in Sources */, - DBF81C8327F6ECF400004A56 /* AppearancePreferenceViewModel.swift in Sources */, + DBBA21192A403BB600CEBCF8 /* HashtagSection.swift in Sources */, DB2C8742274F4B7D00CE0398 /* AboutViewController.swift in Sources */, DB442465285AD8660095AECF /* ListStatusTimelineViewModel+Diffable.swift in Sources */, - DB5FD9B326D8D94600CF5439 /* StatusTableViewCell+ViewModel.swift in Sources */, DB2C0BDA27DF14950033FC94 /* EditListViewController.swift in Sources */, DBA210342759D7A8000B7CB2 /* FriendshipListViewModel.swift in Sources */, DBC747E1259DBD5400787EEF /* AvatarBarButtonItem.swift in Sources */, @@ -3124,6 +3001,7 @@ DB5632A726DCC84C00FC893F /* DataSourceProvider+UITableViewDelegate.swift in Sources */, DB9B324D285732A400AC818D /* UserTimelineViewController.swift in Sources */, DBB47DAD26EB440C001590F7 /* ProfileFieldCollectionViewCell.swift in Sources */, + DBBA21222A403BCC00CEBCF8 /* HistoryItem.swift in Sources */, DBB04A322861AE24003799CA /* TrendPlaceViewController.swift in Sources */, DB3B905F26E8A9650010F64C /* StatusThreadViewController+DataSourceProvider.swift in Sources */, DBF739D1275C3EF300BF6AB5 /* DataSourceFacade+Report.swift in Sources */, @@ -3131,60 +3009,59 @@ DB262A3D27228B4800D18EF3 /* SearchResultViewController.swift in Sources */, DB5FB0122727FD4A006520FA /* SearchUserViewModel+State.swift in Sources */, DB46D12327DB6223003B8BA1 /* ListUserViewController+DataSourceProvider.swift in Sources */, - DB1E48192772CECC0074F6A0 /* SearchItem.swift in Sources */, DB43488D251DFF0F005B599F /* ProfileBannerStatusItemView.swift in Sources */, DBDA7F1E27D2256400BA6BE1 /* ListViewModel+State.swift in Sources */, DBE76D1E2500E65D00DEB0FC /* HomeTimelineViewModel.swift in Sources */, DBD0B4982758B58F0015A388 /* DrawerSidebarPresentationController.swift in Sources */, - DB47AB2F27CE097C00CD73C7 /* ListSection.swift in Sources */, DB5A2288255B9155006CA5B2 /* AccountListViewModel+Diffable.swift in Sources */, + DBBA21172A403BB100CEBCF8 /* SidebarItem.swift in Sources */, DB12BEE727329F55002AA635 /* SearchUserViewController+DataSourceProvider.swift in Sources */, DB42411426C3E55200B6C5F8 /* WelcomeView.swift in Sources */, - DBE71B7A26B7AF5C00DFAB8E /* StubTimelineViewController.swift in Sources */, DB1D7B5525B5A87200397DCD /* TwitterAuthenticationOptionViewModel.swift in Sources */, - DB98DC0E2507862D0087E30F /* TimelineMiddleLoaderTableViewCell.swift in Sources */, DBA210382759EA91000B7CB2 /* FollowingListViewController+DataSourceProvider.swift in Sources */, DB01A9A2276347B60055FABC /* DataSourceFacade+Poll.swift in Sources */, DB2FFF3E258B78B0003DBC19 /* AVPlayer.swift in Sources */, DBCB4047255B683B00DD8D8F /* AccountListTableViewCell.swift in Sources */, - DBF639FC259B333A009E12C8 /* EmptyStateView.swift in Sources */, + DBFCA3F429F9185100B9DCA3 /* HomeListStatusTimelineViewController.swift in Sources */, DBA6B30C27E9B4CB004D052D /* DataSourceFacade+Banner.swift in Sources */, DB25C4C527798E1A00EC1435 /* DataSourceFacade+SavedSearch.swift in Sources */, DBA210332759D79C000B7CB2 /* FollowingListViewController.swift in Sources */, + DBBA211A2A403BBA00CEBCF8 /* NotificationItem.swift in Sources */, DB44246C285AFE770095AECF /* SearchTimelineViewModel+Diffable.swift in Sources */, DBFA471F2859C4AE00C9FF7F /* UserLikeTimelineViewController.swift in Sources */, DBFCC44725667C620016698E /* UILabel.swift in Sources */, DB76A653275F1C4F00A50673 /* MediaPreviewViewController.swift in Sources */, DB76A660275F58CD00A50673 /* MediaPreviewViewController+DataSourceProvider.swift in Sources */, + DBBA21152A403BAA00CEBCF8 /* TabBarItem.swift in Sources */, DBAA89902758DFC9001C273B /* AvatarBarButtonItem+ViewModel.swift in Sources */, DBA6B31127E9CBCC004D052D /* AddListMemberViewModel.swift in Sources */, DB44245D285A48950095AECF /* HashtagTimelineViewModel.swift in Sources */, DB9EC4EA255A6BC7005403AA /* TwitterAccountUnlockViewController.swift in Sources */, DB1E48142772CE850074F6A0 /* SearchViewModel+Diffable.swift in Sources */, DB71C7D1271EB09A00BE3819 /* DataSourceFacade+Friendship.swift in Sources */, + DBFCA3F729F9191F00B9DCA3 /* HostListStatusTimelineViewModel.swift in Sources */, DB442473285B177B0095AECF /* SearchMediaTimelineViewModel.swift in Sources */, + DBBA21162A403BAF00CEBCF8 /* SidebarSection.swift in Sources */, + DB1D3DEA289388CD008F0BD0 /* HistoryViewController.swift in Sources */, + DB92DB3F2898026B0011B564 /* UserHistoryViewController+DataSourceProvider.swift in Sources */, DBCE2E912591A06300926D09 /* UINavigationController.swift in Sources */, DB915406254FC8CE00613473 /* FollowActionButton.swift in Sources */, - DB8761C0274552F800BA7EE2 /* StatusSection.swift in Sources */, DB01092426E6199C005F67D7 /* DataSourceProvider+StatusViewTableViewCellDelegate.swift in Sources */, DB46D11D27DB2807003B8BA1 /* ListUserViewModel.swift in Sources */, DB2C0BDD27DF151D0033FC94 /* EditListView.swift in Sources */, DB25C4C8277994B800EC1435 /* CenterFootnoteLabelTableViewCell.swift in Sources */, DBA210352759D7B1000B7CB2 /* FollowingListViewModel+Diffable.swift in Sources */, DB2C8744274F4B7D00CE0398 /* SettingListViewController.swift in Sources */, + DB58F1EC298BD07400836FBE /* MeProfileViewModel.swift in Sources */, DBE6357F288555AE001C114B /* PushNotificationScratchView.swift in Sources */, - DB47AB3E27CE1F9E00CD73C7 /* TimelineTopLoaderTableViewCell.swift in Sources */, DB0AD4E62858742D0002ABDB /* UserMediaTimelineViewModel+Diffable.swift in Sources */, DB02C77027350D8A007EA0BF /* SearchHashtagViewModel.swift in Sources */, DB5632B226DCF1CD00FC893F /* StatusThreadRootTableViewCell+ViewModel.swift in Sources */, DB0618102786EC870030EE79 /* LineChartView.swift in Sources */, - DB8761CC2745530200BA7EE2 /* CoverFlowStackItem.swift in Sources */, DB2C8741274F4B7D00CE0398 /* AboutView.swift in Sources */, DB442468285AFE680095AECF /* SearchTimelineViewController.swift in Sources */, - DB8761C2274552F800BA7EE2 /* UserSection.swift in Sources */, DB76A65A275F49AE00A50673 /* ProgressBarView.swift in Sources */, DB76A655275F30E800A50673 /* DataSourceFacade+Media.swift in Sources */, - DBC17A5C26E0A4C1005C0D79 /* TimelineMiddleLoaderTableViewCell+ViewModel.swift in Sources */, DB47AB1A27CCB7EC00CD73C7 /* ListViewController.swift in Sources */, DBF63A04259B4811009E12C8 /* TouchBlockingCollectionView.swift in Sources */, DB76A64B275DF9FA00A50673 /* TwitterStatusThreadReplyViewModel.swift in Sources */, @@ -3196,18 +3073,15 @@ DB01091526E5EB64005F67D7 /* MastodonStatusThreadViewModel.swift in Sources */, DBA2103B275A0E91000B7CB2 /* FollowerListViewController+DataSourceProvider.swift in Sources */, DB5632BC26DF503800FC893F /* TwitterStatusThreadLeafViewModel.swift in Sources */, - DBF81C8527F709EF00004A56 /* TranslateButtonPreferenceView.swift in Sources */, DB51DC1A2715581E00A0D8FB /* ProfileDashboardView.swift in Sources */, DB442471285B17730095AECF /* SearchMediaTimelineViewController.swift in Sources */, DB2C873D274F4B7D00CE0398 /* DeveloperViewModel.swift in Sources */, DB9B32542857369800AC818D /* GridTimelineViewController+DataSourceProvider.swift in Sources */, DBD0B4972758B57F0015A388 /* DrawerSidebarTransitionController.swift in Sources */, - DB8761C7274552FF00BA7EE2 /* NotificationSection.swift in Sources */, DB76A657275F498F00A50673 /* MediaPreviewImageViewController.swift in Sources */, DB5632B026DCED1300FC893F /* StatusThreadRootTableViewCell.swift in Sources */, - DB8761C4274552FB00BA7EE2 /* HashtagData.swift in Sources */, + DBFCEF1528939CAC00EEBFB1 /* UserHistoryViewModel.swift in Sources */, DBDA8E2224FCF8A3006750DC /* AppDelegate.swift in Sources */, - DBF81C7E27F6E7F500004A56 /* AppearancePreferenceViewController.swift in Sources */, DB46D11F27DB2B50003B8BA1 /* ListUserViewModel+Diffable.swift in Sources */, DB442459285A42B50095AECF /* HashtagTimelineViewController.swift in Sources */, DB56329726DCBE1600FC893F /* StatusThreadViewController.swift in Sources */, @@ -3222,8 +3096,8 @@ DB74A16E256E50E300C5F3C9 /* UIColor.swift in Sources */, DB676161254BE580006C6798 /* MediaPreviewCollectionViewCell.swift in Sources */, DB01092026E60756005F67D7 /* DataSourceFacade+StatusThread.swift in Sources */, + DBFCEF202893E18400EEBFB1 /* DataSourceFacade+History.swift in Sources */, DBDAF244274F530B00050319 /* NeedsDependency.swift in Sources */, - DB8761C3274552FB00BA7EE2 /* HashtagSection.swift in Sources */, DB9B325C2857493D00AC818D /* UserTimelineViewModel+Diffable.swift in Sources */, DBE6357A28855302001C114B /* PushNotificationScratchViewController.swift in Sources */, DBF81C8E27F843D700004A56 /* AppIconAssets.swift in Sources */, @@ -3231,17 +3105,19 @@ DBF3309125B96E0B00A678FB /* WKNavigationDelegateShim.swift in Sources */, DB580EB5288187BD00BC4A0F /* AccountPreferenceView.swift in Sources */, DB2D36F927D5E74D00C1FBE0 /* CompositeListViewModel.swift in Sources */, + DBBA211B2A403BBA00CEBCF8 /* NotificationSection.swift in Sources */, + DB1B80462A403B0A00C90A7D /* UserItem.swift in Sources */, DBB47DAB26EB3BF8001590F7 /* ProfileFieldContentView.swift in Sources */, DBBF70F726D7B62F00971656 /* AccountListTableViewCell+ViewModel.swift in Sources */, + DB92DB41289804440011B564 /* UserHistoryViewModel+Diffable.swift in Sources */, DBB47DA926EB3AA2001590F7 /* ProfileFieldListView.swift in Sources */, DBF69EE12549705A00E2A915 /* ViewControllerAnimatedTransitioningDelegate.swift in Sources */, - DBE71B7D26B7C5FD00DFAB8E /* StubTimelineViewModel.swift in Sources */, DB76A658275F498F00A50673 /* MediaPreviewImageViewModel.swift in Sources */, - DB47AB2E27CE097C00CD73C7 /* ListItem.swift in Sources */, DBB04A372861AF2D003799CA /* TrendPlaceView.swift in Sources */, DB6BCD6E277ADC0900847054 /* TrendViewModel.swift in Sources */, DB51DC4E27181D7500A0D8FB /* CoverFlowStackMediaCollectionCell.swift in Sources */, DBE6357D2885557C001C114B /* PushNotificationScratchViewModel.swift in Sources */, + DBBA211D2A403BBE00CEBCF8 /* CoverFlowStackItem.swift in Sources */, DBED96D8253F5D7800C5383A /* NamingState.swift in Sources */, DB6DF3E0252060AA00E8A273 /* ProfileViewModel.swift in Sources */, DB262A332721377800D18EF3 /* DataSourceFacade+Block.swift in Sources */, @@ -3251,7 +3127,6 @@ DB67610D254AD681006C6798 /* ActivityIndicatorCollectionViewCell.swift in Sources */, DB94B6B426C65BE100A2E8A1 /* MastodonAuthenticationController.swift in Sources */, DB76A668275F896E00A50673 /* ShareActivityProvider.swift in Sources */, - DB1E48162772CEC20074F6A0 /* SearchSection.swift in Sources */, DB51DC5027181DE500A0D8FB /* CoverFlowStackMediaCollectionCell+ViewModel.swift in Sources */, DB9B325A28573E0000AC818D /* UserTimelineViewModel.swift in Sources */, DB148B03281A7AB300B596C7 /* ContentSplitViewController.swift in Sources */, @@ -3263,11 +3138,12 @@ DB76A66D2760721400A50673 /* MediaPreviewVideoViewModel.swift in Sources */, DB47AB2D27CE085900CD73C7 /* ListViewModel+Diffable.swift in Sources */, DB148B00281A729200B596C7 /* SidebarViewController.swift in Sources */, - DB8761C6274552FF00BA7EE2 /* NotificationItem.swift in Sources */, DBF69EE02549705A00E2A915 /* ViewControllerAnimatedTransitioning.swift in Sources */, DB8E4FEC2563D42800459DA2 /* TwitterPinBasedAuthenticationViewModel.swift in Sources */, + DBBA21272A403BE100CEBCF8 /* StatusMediaGallerySection.swift in Sources */, DB8301F7273CED0400BF5224 /* NotificationTimelineViewController.swift in Sources */, DBAA89922758EF12001C273B /* DrawerSidebarViewModel+Diffable.swift in Sources */, + DBBA211F2A403BC500CEBCF8 /* SearchItem.swift in Sources */, DB5632AD26DCE93900FC893F /* StatusThreadViewModel+Diffable.swift in Sources */, DBF639F7259B1728009E12C8 /* TimelineHeaderCollectionViewCell.swift in Sources */, DBD0B49B2758B5A60015A388 /* DrawerSidebarViewController.swift in Sources */, @@ -3279,25 +3155,27 @@ DB3B906B26E8D3AB0010F64C /* DataSourceFacade+Status.swift in Sources */, DB5FB00F2727FCB7006520FA /* SearchUserViewController.swift in Sources */, DB5632AB26DCCD3900FC893F /* DataSourceFacade.swift in Sources */, - DB8761D02745553700BA7EE2 /* MediaSection.swift in Sources */, DB51DC1C2715588E00A0D8FB /* ProfileDashboardMeterView.swift in Sources */, + DBF87AD52892A6740029A7C7 /* AppIconPreferenceView.swift in Sources */, DBB04A352861AE4D003799CA /* TrendPlaceViewModel.swift in Sources */, DB2C8743274F4B7D00CE0398 /* SettingListView.swift in Sources */, DB76A650275F1C3200A50673 /* MediaPreviewTransitionController.swift in Sources */, - DBF81C8027F6E80700004A56 /* AppearancePreferenceView.swift in Sources */, - DB8761C5274552FB00BA7EE2 /* HashtagItem.swift in Sources */, + DB88E62E2890DF7E009A01F5 /* BehaviorsPreferenceView.swift in Sources */, DB76A65B275F49AE00A50673 /* MediaInfoDescriptionView.swift in Sources */, DB76A64D275DFA0600A50673 /* TwitterStatusThreadReplyViewModel+State.swift in Sources */, + DBF87AD92892A67D0029A7C7 /* TranslateButtonPreferenceView.swift in Sources */, DB76A66A27606F6900A50673 /* MediaPreviewVideoViewController.swift in Sources */, + DB0455B82A1CA510009A00EF /* NewColumnView.swift in Sources */, DB148B0C281A837E00B596C7 /* SidebarView.swift in Sources */, + DB88E6292890DF67009A01F5 /* BehaviorsPreferenceViewController.swift in Sources */, DBC8E050257653E100401E20 /* SavePhotoActivity.swift in Sources */, DB1D7B4325B5938400397DCD /* TwitterAuthenticationOptionViewController.swift in Sources */, DB830209273D12E600BF5224 /* NotificationTimelineViewController+DataSourceProvider.swift in Sources */, - DB8761C1274552F800BA7EE2 /* UserItem.swift in Sources */, DB5FB0102727FCC5006520FA /* SearchUserViewModel.swift in Sources */, + DBBA211C2A403BBE00CEBCF8 /* CoverFlowStackSection.swift in Sources */, + DB88E62C2890DF79009A01F5 /* BehaviorsPreferenceViewModel.swift in Sources */, DB5632A926DCC96C00FC893F /* ListTimelineViewController+DataSourceProvider.swift in Sources */, DB56329A26DCBE7300FC893F /* StatusThreadViewModel.swift in Sources */, - DBF81C9127F8448C00004A56 /* AppIconPreferenceView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3316,6 +3194,7 @@ buildActionMask = 2147483647; files = ( DBDA7F1A27D1CBCA00BA6BE1 /* TwidereXUITests+Onboarding.swift in Sources */, + DB8BFB1C29D1F52900535092 /* TwidereXUITests+Performance.swift in Sources */, DBDA8E4424FCF8A7006750DC /* TwidereXUITests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -3346,29 +3225,16 @@ target = DB7FF05D28853A7F00BFD55E /* NotificationService */; targetProxy = DB7FF06328853A7F00BFD55E /* PBXContainerItemProxy */; }; - DB7FF07128853B1F00BFD55E /* PBXTargetDependency */ = { + DB8BFB1E29D1FC6200535092 /* PBXTargetDependency */ = { isa = PBXTargetDependency; - target = DB94B6BA26C65CB000A2E8A1 /* AppShared */; - targetProxy = DB7FF07028853B1F00BFD55E /* PBXContainerItemProxy */; - }; - DB94B6C026C65CB000A2E8A1 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = DB94B6BA26C65CB000A2E8A1 /* AppShared */; - targetProxy = DB94B6BF26C65CB000A2E8A1 /* PBXContainerItemProxy */; - }; - DBBBBE742744EC27007ACB4B /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = DB94B6BA26C65CB000A2E8A1 /* AppShared */; - targetProxy = DBBBBE732744EC27007ACB4B /* PBXContainerItemProxy */; + productRef = DB8BFB1D29D1FC6200535092 /* TwidereSDK */; }; DBBBBE822744F39C007ACB4B /* PBXTargetDependency */ = { isa = PBXTargetDependency; - target = DB94B6BA26C65CB000A2E8A1 /* AppShared */; targetProxy = DBBBBE812744F39C007ACB4B /* PBXContainerItemProxy */; }; DBBBBE892744F535007ACB4B /* PBXTargetDependency */ = { isa = PBXTargetDependency; - target = DB94B6BA26C65CB000A2E8A1 /* AppShared */; targetProxy = DBBBBE882744F535007ACB4B /* PBXContainerItemProxy */; }; DBDA8E3624FCF8A7006750DC /* PBXTargetDependency */ = { @@ -3386,11 +3252,6 @@ target = DBFDCE3F27F450FC00BE99E3 /* TwidereXIntent */; targetProxy = DBFDCE4627F450FC00BE99E3 /* PBXContainerItemProxy */; }; - DBFDCE5727F451FC00BE99E3 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = DB94B6BA26C65CB000A2E8A1 /* AppShared */; - targetProxy = DBFDCE5627F451FC00BE99E3 /* PBXContainerItemProxy */; - }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -3457,22 +3318,6 @@ name = LaunchScreen.storyboard; sourceTree = ""; }; - DBE76CF02500B29300DEB0FC /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - DBE76CF12500B29300DEB0FC /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; - DBE76CF52500B29500DEB0FC /* LaunchScreen.storyboard */ = { - isa = PBXVariantGroup; - children = ( - DBE76CF62500B29500DEB0FC /* Base */, - ); - name = LaunchScreen.storyboard; - sourceTree = ""; - }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ @@ -3550,21 +3395,26 @@ CODE_SIGN_ENTITLEMENTS = TwidereX/TwidereX.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 117; + CURRENT_PROJECT_VERSION = 140; DEVELOPMENT_ASSET_PATHS = "TwidereX/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = TwidereX/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.5; + MARKETING_VERSION = 2.0.0; PRODUCT_BUNDLE_IDENTIFIER = com.twidere.TwidereX; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_OBJC_BRIDGING_HEADER = "TwidereX/Supporting Files/TwidereX-Bridging-Header.h"; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = "1,2,7"; VERSIONING_SYSTEM = "apple-generic"; }; name = Profile; @@ -3576,15 +3426,15 @@ ASSETCATALOG_COMPILER_APPICON_NAME = Twidere; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 117; + CURRENT_PROJECT_VERSION = 140; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = TwidereXTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 1.2.5; PRODUCT_BUNDLE_IDENTIFIER = com.twidere.TwidereXTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -3599,15 +3449,15 @@ baseConfigurationReference = BC377CE93F0DE1E07208F697 /* Pods-TwidereX-TwidereXUITests.profile.xcconfig */; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 117; + CURRENT_PROJECT_VERSION = 140; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = TwidereXUITests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 1.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.twidere.TwidereXUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -3617,57 +3467,23 @@ }; name = Profile; }; - DB2F180A282B54F30001C6A8 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 8602431405CD5A4D45224B80 /* Pods-AppShared.profile.xcconfig */; - buildSettings = { - APPLICATION_EXTENSION_API_ONLY = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 117; - DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = 7LFDZ96332; - DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 117; - DYLIB_INSTALL_NAME_BASE = "@rpath"; - INFOPLIST_FILE = ShareExtension/Info.plist; - INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2021 Twidere. All rights reserved."; - INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - MARKETING_VERSION = 1.2.0; - PRODUCT_BUNDLE_IDENTIFIER = com.twidere.AppShared; - PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; - SKIP_INSTALL = YES; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Profile; - }; DB2F180B282B54F30001C6A8 /* Profile */ = { isa = XCBuildConfiguration; buildSettings = { CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 117; + CURRENT_PROJECT_VERSION = 140; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2021 Twidere. All rights reserved."; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.twidere.TwidereX.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -3684,18 +3500,17 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CODE_SIGN_ENTITLEMENTS = TwidereXIntent/TwidereXIntent.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 117; + CURRENT_PROJECT_VERSION = 140; DEVELOPMENT_TEAM = 7LFDZ96332; - GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = TwidereXIntent/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = TwidereXIntent; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 Twidere. All rights reserved."; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.twidere.TwidereX.TwidereXIntent; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -3714,18 +3529,17 @@ CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 117; + CURRENT_PROJECT_VERSION = 140; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 7LFDZ96332; - GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = NotificationService/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 Twidere. All rights reserved."; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.2.5; PRODUCT_BUNDLE_IDENTIFIER = com.twidere.TwidereX.NotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -3744,17 +3558,16 @@ CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 117; + CURRENT_PROJECT_VERSION = 140; DEVELOPMENT_TEAM = 7LFDZ96332; - GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = NotificationService/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 Twidere. All rights reserved."; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.2.5; PRODUCT_BUNDLE_IDENTIFIER = com.twidere.TwidereX.NotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -3773,17 +3586,16 @@ CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 117; + CURRENT_PROJECT_VERSION = 140; DEVELOPMENT_TEAM = 7LFDZ96332; - GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = NotificationService/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 Twidere. All rights reserved."; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.2.5; PRODUCT_BUNDLE_IDENTIFIER = com.twidere.TwidereX.NotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -3794,90 +3606,23 @@ }; name = Release; }; - DB94B6C426C65CB100A2E8A1 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 5635061D4E24E9A2C69E90ED /* Pods-AppShared.debug.xcconfig */; - buildSettings = { - APPLICATION_EXTENSION_API_ONLY = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 117; - DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = 7LFDZ96332; - DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 117; - DYLIB_INSTALL_NAME_BASE = "@rpath"; - INFOPLIST_FILE = ShareExtension/Info.plist; - INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2021 Twidere. All rights reserved."; - INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - MARKETING_VERSION = 1.2.0; - PRODUCT_BUNDLE_IDENTIFIER = com.twidere.AppShared; - PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; - SKIP_INSTALL = YES; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Debug; - }; - DB94B6C526C65CB100A2E8A1 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 06C6C02930FE7CBB4E13D99E /* Pods-AppShared.release.xcconfig */; - buildSettings = { - APPLICATION_EXTENSION_API_ONLY = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 117; - DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = 7LFDZ96332; - DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 117; - DYLIB_INSTALL_NAME_BASE = "@rpath"; - INFOPLIST_FILE = ShareExtension/Info.plist; - INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2021 Twidere. All rights reserved."; - INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - MARKETING_VERSION = 1.2.0; - PRODUCT_BUNDLE_IDENTIFIER = com.twidere.AppShared; - PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; - SKIP_INSTALL = YES; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Release; - }; DBBBBE6A2744E8CC007ACB4B /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 117; + CURRENT_PROJECT_VERSION = 140; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2021 Twidere. All rights reserved."; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.twidere.TwidereX.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -3894,17 +3639,17 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 117; + CURRENT_PROJECT_VERSION = 140; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2021 Twidere. All rights reserved."; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.twidere.TwidereX.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -4048,22 +3793,27 @@ CODE_SIGN_ENTITLEMENTS = TwidereX/TwidereX.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 117; + CURRENT_PROJECT_VERSION = 140; DEVELOPMENT_ASSET_PATHS = "TwidereX/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = TwidereX/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.5; + MARKETING_VERSION = 2.0.0; PRODUCT_BUNDLE_IDENTIFIER = com.twidere.TwidereX; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_OBJC_BRIDGING_HEADER = "TwidereX/Supporting Files/TwidereX-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = "1,2,7"; VERSIONING_SYSTEM = "apple-generic"; }; name = Debug; @@ -4079,21 +3829,26 @@ CODE_SIGN_ENTITLEMENTS = TwidereX/TwidereX.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 117; + CURRENT_PROJECT_VERSION = 140; DEVELOPMENT_ASSET_PATHS = "TwidereX/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = TwidereX/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.5; + MARKETING_VERSION = 2.0.0; PRODUCT_BUNDLE_IDENTIFIER = com.twidere.TwidereX; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_OBJC_BRIDGING_HEADER = "TwidereX/Supporting Files/TwidereX-Bridging-Header.h"; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = "1,2,7"; VERSIONING_SYSTEM = "apple-generic"; }; name = Release; @@ -4105,15 +3860,15 @@ ASSETCATALOG_COMPILER_APPICON_NAME = Twidere; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 117; + CURRENT_PROJECT_VERSION = 140; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = TwidereXTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 1.2.5; PRODUCT_BUNDLE_IDENTIFIER = com.twidere.TwidereXTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -4130,15 +3885,15 @@ ASSETCATALOG_COMPILER_APPICON_NAME = Twidere; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 117; + CURRENT_PROJECT_VERSION = 140; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = TwidereXTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 1.2.5; PRODUCT_BUNDLE_IDENTIFIER = com.twidere.TwidereXTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -4153,15 +3908,15 @@ baseConfigurationReference = 2E41729E598B3379FC09BC03 /* Pods-TwidereX-TwidereXUITests.debug.xcconfig */; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 117; + CURRENT_PROJECT_VERSION = 140; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = TwidereXUITests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 1.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.twidere.TwidereXUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -4176,15 +3931,15 @@ baseConfigurationReference = 44D01BA9E21B7F374DBDA38A /* Pods-TwidereX-TwidereXUITests.release.xcconfig */; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 117; + CURRENT_PROJECT_VERSION = 140; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = TwidereXUITests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 1.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.twidere.TwidereXUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -4200,18 +3955,17 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CODE_SIGN_ENTITLEMENTS = TwidereXIntent/TwidereXIntent.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 117; + CURRENT_PROJECT_VERSION = 140; DEVELOPMENT_TEAM = 7LFDZ96332; - GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = TwidereXIntent/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = TwidereXIntent; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 Twidere. All rights reserved."; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.twidere.TwidereX.TwidereXIntent; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -4228,18 +3982,17 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CODE_SIGN_ENTITLEMENTS = TwidereXIntent/TwidereXIntent.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 117; + CURRENT_PROJECT_VERSION = 140; DEVELOPMENT_TEAM = 7LFDZ96332; - GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = TwidereXIntent/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = TwidereXIntent; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 Twidere. All rights reserved."; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.twidere.TwidereX.TwidereXIntent; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -4263,16 +4016,6 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - DB94B6C326C65CB100A2E8A1 /* Build configuration list for PBXNativeTarget "AppShared" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - DB94B6C426C65CB100A2E8A1 /* Debug */, - DB2F180A282B54F30001C6A8 /* Profile */, - DB94B6C526C65CB100A2E8A1 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; DBBBBE692744E8CC007ACB4B /* Build configuration list for PBXNativeTarget "ShareExtension" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -4335,82 +4078,26 @@ }; /* End XCConfigurationList section */ -/* Begin XCRemoteSwiftPackageReference section */ - DB2611B5251B2D42004BF309 /* XCRemoteSwiftPackageReference "CryptoSwift" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/krzyzanowskim/CryptoSwift.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.3.2; - }; - }; - DB6CF1C2269715CD001DE069 /* XCRemoteSwiftPackageReference "FPSIndicator" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/MainasuK/FPSIndicator.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.0.0; - }; - }; - DB7AB33C2744E3740035EB8A /* XCRemoteSwiftPackageReference "Floaty" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/kciter/Floaty.git"; - requirement = { - branch = master; - kind = branch; - }; - }; - DBD98487251CB88A00ED87A1 /* XCRemoteSwiftPackageReference "Tabman" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/uias/Tabman.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 2.9.1; - }; - }; - DBEA4F822511F7460007FEC5 /* XCRemoteSwiftPackageReference "Kanna" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/tid-kijyun/Kanna.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 5.2.2; - }; - }; -/* End XCRemoteSwiftPackageReference section */ - /* Begin XCSwiftPackageProductDependency section */ - DB2611B6251B2D42004BF309 /* CryptoSwift */ = { - isa = XCSwiftPackageProductDependency; - package = DB2611B5251B2D42004BF309 /* XCRemoteSwiftPackageReference "CryptoSwift" */; - productName = CryptoSwift; - }; - DB6CF1C3269715CD001DE069 /* FPSIndicator */ = { - isa = XCSwiftPackageProductDependency; - package = DB6CF1C2269715CD001DE069 /* XCRemoteSwiftPackageReference "FPSIndicator" */; - productName = FPSIndicator; - }; - DB7AB33D2744E3740035EB8A /* Floaty */ = { + DB05E13629C318530055BF3F /* TwidereSDK */ = { isa = XCSwiftPackageProductDependency; - package = DB7AB33C2744E3740035EB8A /* XCRemoteSwiftPackageReference "Floaty" */; - productName = Floaty; + productName = TwidereSDK; }; - DBB0E3C22760FB1200F1D45F /* TwidereSDK */ = { + DB05E13829C318590055BF3F /* TwidereSDK */ = { isa = XCSwiftPackageProductDependency; productName = TwidereSDK; }; - DBD98488251CB88A00ED87A1 /* Tabman */ = { + DB05E13A29C3185E0055BF3F /* TwidereSDK */ = { isa = XCSwiftPackageProductDependency; - package = DBD98487251CB88A00ED87A1 /* XCRemoteSwiftPackageReference "Tabman" */; - productName = Tabman; + productName = TwidereSDK; }; - DBEA4F832511F7460007FEC5 /* Kanna */ = { + DB05E13C29C321960055BF3F /* TwidereSDK */ = { isa = XCSwiftPackageProductDependency; - package = DBEA4F822511F7460007FEC5 /* XCRemoteSwiftPackageReference "Kanna" */; - productName = Kanna; + productName = TwidereSDK; }; - DBFADA882872BEDA00B512D6 /* CoverFlowStackCollectionViewLayout */ = { + DB8BFB1D29D1FC6200535092 /* TwidereSDK */ = { isa = XCSwiftPackageProductDependency; - productName = CoverFlowStackCollectionViewLayout; + productName = TwidereSDK; }; DBFADA8A2872BEDE00B512D6 /* TabBarPager */ = { isa = XCSwiftPackageProductDependency; diff --git a/TwidereX.xcodeproj/xcshareddata/xcbaselines/DBDA8E3E24FCF8A7006750DC.xcbaseline/D687BB06-F0F4-4247-8624-A87B4DA38312.plist b/TwidereX.xcodeproj/xcshareddata/xcbaselines/DBDA8E3E24FCF8A7006750DC.xcbaseline/D687BB06-F0F4-4247-8624-A87B4DA38312.plist new file mode 100644 index 00000000..99cf8774 --- /dev/null +++ b/TwidereX.xcodeproj/xcshareddata/xcbaselines/DBDA8E3E24FCF8A7006750DC.xcbaseline/D687BB06-F0F4-4247-8624-A87B4DA38312.plist @@ -0,0 +1,22 @@ + + + + + classNames + + TwidereXUITests_Performance + + testHomeTimelineScrollingAnimationPerformance() + + com.apple.dt.XCTMetric_OSSignpost-Scroll_DraggingAndDeceleration.animation.hitch.number + + baselineAverage + 0.000000 + baselineIntegrationDisplayName + Local Baseline + + + + + + diff --git a/TwidereX.xcodeproj/xcshareddata/xcbaselines/DBDA8E3E24FCF8A7006750DC.xcbaseline/Info.plist b/TwidereX.xcodeproj/xcshareddata/xcbaselines/DBDA8E3E24FCF8A7006750DC.xcbaseline/Info.plist new file mode 100644 index 00000000..9a2d9f0e --- /dev/null +++ b/TwidereX.xcodeproj/xcshareddata/xcbaselines/DBDA8E3E24FCF8A7006750DC.xcbaseline/Info.plist @@ -0,0 +1,21 @@ + + + + + runDestinationsByUUID + + D687BB06-F0F4-4247-8624-A87B4DA38312 + + targetArchitecture + arm64 + targetDevice + + modelCode + iPhone14,6 + platformIdentifier + com.apple.platform.iphoneos + + + + + diff --git a/TwidereX.xcodeproj/xcshareddata/xcschemes/TwidereX - Core Data.xcscheme b/TwidereX.xcodeproj/xcshareddata/xcschemes/TwidereX - Core Data.xcscheme index 420a130a..bb2c58c2 100644 --- a/TwidereX.xcodeproj/xcshareddata/xcschemes/TwidereX - Core Data.xcscheme +++ b/TwidereX.xcodeproj/xcshareddata/xcschemes/TwidereX - Core Data.xcscheme @@ -29,10 +29,10 @@ shouldUseLaunchSchemeArgsEnv = "YES"> + reference = "container:TwidereX/New Group/TwidereX-Performance.xctestplan"> diff --git a/TwidereX.xcodeproj/xcshareddata/xcschemes/TwidereX - Performance.xcscheme b/TwidereX.xcodeproj/xcshareddata/xcschemes/TwidereX - Performance.xcscheme new file mode 100644 index 00000000..5887e377 --- /dev/null +++ b/TwidereX.xcodeproj/xcshareddata/xcschemes/TwidereX - Performance.xcscheme @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TwidereX.xcodeproj/xcshareddata/xcschemes/TwidereX - Profile.xcscheme b/TwidereX.xcodeproj/xcshareddata/xcschemes/TwidereX - Profile.xcscheme index 574ca276..ced81db7 100644 --- a/TwidereX.xcodeproj/xcshareddata/xcschemes/TwidereX - Profile.xcscheme +++ b/TwidereX.xcodeproj/xcshareddata/xcschemes/TwidereX - Profile.xcscheme @@ -29,10 +29,10 @@ shouldUseLaunchSchemeArgsEnv = "YES"> + reference = "container:TwidereX/New Group/TwidereX-Performance.xctestplan"> diff --git a/TwidereX.xcodeproj/xcshareddata/xcschemes/TwidereX - Release.xcscheme b/TwidereX.xcodeproj/xcshareddata/xcschemes/TwidereX - Release.xcscheme index 5f5fec0b..4cf04b3b 100644 --- a/TwidereX.xcodeproj/xcshareddata/xcschemes/TwidereX - Release.xcscheme +++ b/TwidereX.xcodeproj/xcshareddata/xcschemes/TwidereX - Release.xcscheme @@ -29,10 +29,10 @@ shouldUseLaunchSchemeArgsEnv = "YES"> + reference = "container:TwidereX/New Group/TwidereX-Performance.xctestplan"> diff --git a/TwidereX.xcodeproj/xcshareddata/xcschemes/TwidereX.xcscheme b/TwidereX.xcodeproj/xcshareddata/xcschemes/TwidereX.xcscheme index 9c63e642..0f717735 100644 --- a/TwidereX.xcodeproj/xcshareddata/xcschemes/TwidereX.xcscheme +++ b/TwidereX.xcodeproj/xcshareddata/xcschemes/TwidereX.xcscheme @@ -29,10 +29,10 @@ shouldUseLaunchSchemeArgsEnv = "YES"> + reference = "container:TwidereX/New Group/TwidereX-Performance.xctestplan"> @@ -73,6 +73,8 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + enableThreadSanitizer = "YES" + enableUBSanitizer = "YES" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" @@ -89,6 +91,28 @@ ReferencedContainer = "container:TwidereX.xcodeproj"> + + + + + + + + + + + + Void)? = nil) - case custom(transitioningDelegate: UIViewControllerTransitioningDelegate) + case custom(animated: Bool, transitioningDelegate: UIViewControllerTransitioningDelegate) case customPush case safariPresent(animated: Bool, completion: (() -> Void)? = nil) case activityViewControllerPresent(animated: Bool, completion: (() -> Void)? = nil) @@ -93,16 +94,18 @@ extension SceneCoordinator { case trendPlace(viewModel: TrendViewModel) case searchResult(viewModel: SearchResultViewModel) + // Hisotry + case history(viewModel: HistoryViewModel) + // Settings case setting(viewModel: SettingListViewModel) case accountPreference(viewModel: AccountPreferenceViewModel) - case appearancePreference - case displayPreference - case about + case behaviorsPreference(viewModel: BehaviorsPreferenceViewModel) + case displayPreference(viewModel: DisplayPreferenceViewModel) + case about(viewModel: AboutViewModel) #if DEBUG - case developer - case stubTimeline + case developer(viewModel: DeveloperViewModel) case pushNotificationScratch #endif @@ -115,48 +118,72 @@ extension SceneCoordinator { extension SceneCoordinator { - func setup() { + @MainActor + func setup(authentication record: ManagedObjectRecord? = nil) { let rootViewController: UIViewController - switch UIDevice.current.userInterfaceIdiom { - case .phone: - let viewController = MainTabBarController(context: context, coordinator: self) - rootViewController = viewController - needsSetupAvatarBarButtonItem = true - default: - let contentSplitViewController = ContentSplitViewController() - contentSplitViewController.context = context - contentSplitViewController.coordinator = self - rootViewController = contentSplitViewController - contentSplitViewController.$isSidebarDisplay - .sink { [weak self] isSidebarDisplay in - guard let self = self else { return } - self.needsSetupAvatarBarButtonItem = !isSidebarDisplay - } - .store(in: &contentSplitViewController.disposeBag) - } - sceneDelegate.window?.rootViewController = rootViewController - } - - func setupWelcomeIfNeeds() { do { - let request = AuthenticationIndex.sortedFetchRequest - let count = try context.managedObjectContext.count(for: request) - if count == 0 { + // check AuthContext + let _authenticationIndex: AuthenticationIndex? = try { + if let index = record?.object(in: context.managedObjectContext) { + return index + } else { + let request = AuthenticationIndex.sortedFetchRequest + request.fetchLimit = 1 + let result = try context.managedObjectContext.fetch(request).first + return result + } + }() + guard let authenticationIndex = _authenticationIndex, + let authContext = AuthContext(authenticationIndex: authenticationIndex) + else { + // no AuthContext, use empty ViewController as root and show welcome via modal + let configuration = WelcomeViewModel.Configuration(allowDismissModal: false) + let welcomeViewModel = WelcomeViewModel(context: context, configuration: configuration) + sceneDelegate.window?.rootViewController = UIViewController() + // use async without animation modal to fix the UIKit safe-area not take effect issue DispatchQueue.main.async { - let configuration = WelcomeViewModel.Configuration(allowDismissModal: false) - let welcomeViewModel = WelcomeViewModel(context: self.context, configuration: configuration) - self.present(scene: .welcome(viewModel: welcomeViewModel), from: nil, transition: .modal(animated: false, completion: nil)) + self.present( + scene: .welcome(viewModel: welcomeViewModel), + from: nil, + transition: .modal(animated: false) + ) // entry #1: Welcome } + self.authContext = nil + return + } + + self.authContext = authContext + + switch UIDevice.current.userInterfaceIdiom { + case .phone: + let viewController = MainTabBarController(context: context, coordinator: self, authContext: authContext) + rootViewController = viewController + needsSetupAvatarBarButtonItem = true + default: + let contentSplitViewController = ContentSplitViewController(context: context, coordinator: self, authContext: authContext) + rootViewController = contentSplitViewController + contentSplitViewController.$isSidebarDisplay + .sink { [weak self] isSidebarDisplay in + guard let self = self else { return } + self.needsSetupAvatarBarButtonItem = !isSidebarDisplay + } + .store(in: &contentSplitViewController.disposeBag) } + sceneDelegate.window?.rootViewController = rootViewController // entry #2: main app } catch { assertionFailure(error.localizedDescription) + self.authContext = nil + Task { + try? await Task.sleep(nanoseconds: .second * 2) + setup() // entry #3: retry + } // end Task } } - @discardableResult @MainActor + @discardableResult func present(scene: Scene, from sender: UIViewController?, transition: Transition) -> UIViewController? { guard let viewController = get(scene: scene) else { return nil @@ -208,10 +235,10 @@ extension SceneCoordinator { } presentingViewController.present(modalNavigationController, animated: animated, completion: completion) - case .custom(let transitioningDelegate): + case .custom(let animated, let transitioningDelegate): viewController.modalPresentationStyle = .custom viewController.transitioningDelegate = transitioningDelegate - sender?.present(viewController, animated: true, completion: nil) + sender?.present(viewController, animated: animated, completion: nil) case .customPush: // set delegate in view controller @@ -244,7 +271,7 @@ extension SceneCoordinator { } -private extension SceneCoordinator { +extension SceneCoordinator { func get(scene: Scene) -> UIViewController? { let viewController: UIViewController? @@ -355,6 +382,10 @@ private extension SceneCoordinator { _viewController.searchResultViewModel = viewModel _viewController.searchResultViewController = searchResultViewController viewController = _viewController + case .history(let viewModel): + let _viewController = HistoryViewController() + _viewController.viewModel = viewModel + viewController = _viewController case .setting(let viewModel): let _viewController = SettingListViewController() _viewController.viewModel = viewModel @@ -363,17 +394,23 @@ private extension SceneCoordinator { let _viewController = AccountPreferenceViewController() _viewController.viewModel = viewModel viewController = _viewController - case .appearancePreference: - viewController = AppearancePreferenceViewController() - case .displayPreference: - viewController = DisplayPreferenceViewController() - case .about: - viewController = AboutViewController() + case .behaviorsPreference(let viewModel): + let _viewController = BehaviorsPreferenceViewController() + _viewController.viewModel = viewModel + viewController = _viewController + case .displayPreference(let viewModel): + let _viewController = DisplayPreferenceViewController() + _viewController.viewModel = viewModel + viewController = _viewController + case .about(let viewModel): + let _viewController = AboutViewController() + _viewController.viewModel = viewModel + viewController = _viewController #if DEBUG - case .developer: - viewController = DeveloperViewController() - case .stubTimeline: - viewController = StubTimelineViewController() + case .developer(let viewModel): + let _viewController = DeveloperViewController() + _viewController.viewModel = viewModel + viewController = _viewController case .pushNotificationScratch: viewController = PushNotificationScratchViewController() #endif @@ -436,13 +473,19 @@ extension SceneCoordinator { return } + let mastodonAuthenticationContext = MastodonAuthenticationContext(authentication: authentication) + let authConext = AuthContext(authenticationContext: .mastodon(authenticationContext: mastodonAuthenticationContext)) + // 1. active notification account - guard let currentAuthenticationContext = context.authenticationService.activeAuthenticationContext else { - // discard task if no available account - return - } + let authenticationIndexRequest = AuthenticationIndex.sortedFetchRequest + authenticationIndexRequest.fetchLimit = 1 + let _authenticationIndex = try context.managedObjectContext.fetch(authenticationIndexRequest).first + guard let authenticationIndex = _authenticationIndex, + let currentAuthenticationContext = AuthContext(authenticationIndex: authenticationIndex) + else { return } + let needsSwitchActiveAccount: Bool = { - switch currentAuthenticationContext { + switch currentAuthenticationContext.authenticationContext { case .mastodon(let authenticationContext): let result = authenticationContext.authorization.accessToken != pushNotification.accessToken return result @@ -477,6 +520,7 @@ extension SceneCoordinator { case .follow: let remoteProfileViewModel = RemoteProfileViewModel( context: context, + authContext: authConext, profileContext: .mastodon(.userID(notification.account.id)) ) present( @@ -503,7 +547,8 @@ extension SceneCoordinator { } let statusThreadViewModel = StatusThreadViewModel( context: context, - root: .root(context: .init(status: .mastodon(record: root.asRecrod))) + authContext: authConext, + kind: .status(.mastodon(record: root.asRecrod)) ) present( scene: .statusThread(viewModel: statusThreadViewModel), diff --git a/TwidereX/Diffable/Misc/CoverFlowStack/CoverFlowStackItem.swift b/TwidereX/Diffable/Misc/CoverFlowStack/CoverFlowStackItem.swift index a166c28c..40728633 100644 --- a/TwidereX/Diffable/Misc/CoverFlowStack/CoverFlowStackItem.swift +++ b/TwidereX/Diffable/Misc/CoverFlowStack/CoverFlowStackItem.swift @@ -9,5 +9,5 @@ import Foundation enum CoverFlowStackItem: Hashable { - case media(configuration: MediaView.Configuration) + case media(viewModel: MediaView.ViewModel) } diff --git a/TwidereX/Diffable/Misc/CoverFlowStack/CoverFlowStackSection.swift b/TwidereX/Diffable/Misc/CoverFlowStack/CoverFlowStackSection.swift index f5963e34..7cec900a 100644 --- a/TwidereX/Diffable/Misc/CoverFlowStack/CoverFlowStackSection.swift +++ b/TwidereX/Diffable/Misc/CoverFlowStack/CoverFlowStackSection.swift @@ -7,6 +7,7 @@ // import UIKit +import TwidereCore enum CoverFlowStackSection: Hashable { case main @@ -22,7 +23,7 @@ extension CoverFlowStackSection { configuration: Configuration ) -> UICollectionViewDiffableDataSource { - let mediaCell = UICollectionView.CellRegistration { cell, indexPath, configuration in + let mediaCell = UICollectionView.CellRegistration { cell, indexPath, configuration in cell.configure(configuration: configuration) } diff --git a/TwidereX/Diffable/Misc/History/HistoryItem.swift b/TwidereX/Diffable/Misc/History/HistoryItem.swift new file mode 100644 index 00000000..a17f7a76 --- /dev/null +++ b/TwidereX/Diffable/Misc/History/HistoryItem.swift @@ -0,0 +1,16 @@ +// +// HistoryItem.swift +// TwidereX +// +// Created by MainasuK on 2022-7-29. +// Copyright © 2022 Twidere. All rights reserved. +// + + +import Foundation +import CoreDataStack +import TwidereCore + +enum HistoryItem: Hashable { + case history(record: ManagedObjectRecord) +} diff --git a/TwidereX/Diffable/Misc/History/HistorySection.swift b/TwidereX/Diffable/Misc/History/HistorySection.swift new file mode 100644 index 00000000..a7fd4dfe --- /dev/null +++ b/TwidereX/Diffable/Misc/History/HistorySection.swift @@ -0,0 +1,137 @@ +// +// HistorySection.swift +// TwidereX +// +// Created by MainasuK on 2022-7-29. +// Copyright © 2022 Twidere. All rights reserved. +// + +import os.log +import UIKit +import SwiftUI +import Combine +import CoreData +import CoreDataStack +import MetaTextKit +import TwitterSDK + +enum HistorySection: Hashable { + case group(identifer: String) +} + +extension HistorySection { + + static let logger = Logger(subsystem: "StatusSection", category: "Logic") + + struct Configuration { + weak var statusViewTableViewCellDelegate: StatusViewTableViewCellDelegate? + weak var userViewTableViewCellDelegate: UserViewTableViewCellDelegate? + let viewLayoutFramePublisher: Published.Publisher? + } + + static func diffableDataSource( + tableView: UITableView, + context: AppContext, + authContext: AuthContext, + configuration: Configuration + ) -> UITableViewDiffableDataSource { + tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self)) + tableView.register(UserTableViewCell.self, forCellReuseIdentifier: String(describing: UserTableViewCell.self)) + + let diffableDataSource = UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item in + // data source should dispatch in main thread + assert(Thread.isMainThread) + + switch item { + case .history(let record): + let cell: UITableViewCell = context.managedObjectContext.performAndWait { + guard let history = record.object(in: context.managedObjectContext) else { + assertionFailure() + return UITableViewCell() + } + // status + if let status = history.statusObject { + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell + cell.statusViewTableViewCellDelegate = configuration.statusViewTableViewCellDelegate + + let viewModel = StatusView.ViewModel( + status: status, + authContext: authContext, + delegate: cell, + viewLayoutFramePublisher: configuration.viewLayoutFramePublisher + ) + cell.contentConfiguration = UIHostingConfiguration { + StatusView(viewModel: viewModel) + } + .margins(.vertical, 0) // remove vertical margins + return cell + } + // user + if let user = history.userObject { + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: UserTableViewCell.self), for: indexPath) as! UserTableViewCell + cell.userViewTableViewCellDelegate = configuration.userViewTableViewCellDelegate + + let _viewModel = UserView.ViewModel( + user: user, + authContext: authContext, + kind: .history, + delegate: cell + ) + guard let viewModel = _viewModel else { + return UITableViewCell() + } + cell.contentConfiguration = UIHostingConfiguration { + UserView(viewModel: viewModel) + } + .margins(.vertical, 0) // remove vertical margins + return cell + } + + assertionFailure() + return UITableViewCell() + } + return cell + } + } + + return diffableDataSource + } // end func + +} + +extension HistorySection { + +// static func configure( +// tableView: UITableView, +// cell: StatusTableViewCell, +// viewModel: StatusTableViewCell.ViewModel, +// configuration: Configuration +// ) { +// StatusSection.configure( +// tableView: tableView, +// cell: cell, +// viewModel: viewModel, +// configuration: .init( +// statusViewTableViewCellDelegate: configuration.statusViewTableViewCellDelegate, +// timelineMiddleLoaderTableViewCellDelegate: nil, +// statusViewConfigurationContext: configuration.statusViewConfigurationContext +// ) +// ) +// } +// +// static func configure( +// cell: UserTableViewCell, +// viewModel: UserTableViewCell.ViewModel, +// configuration: Configuration +// ) { +// UserSection.configure( +// cell: cell, +// viewModel: viewModel, +// configuration: .init( +// userViewTableViewCellDelegate: configuration.userViewTableViewCellDelegate, +// userViewConfigurationContext: configuration.userViewConfigurationContext +// ) +// ) +// } + +} diff --git a/TwidereX/Diffable/Misc/List/ListSection.swift b/TwidereX/Diffable/Misc/List/ListSection.swift index 88fde9e3..27936934 100644 --- a/TwidereX/Diffable/Misc/List/ListSection.swift +++ b/TwidereX/Diffable/Misc/List/ListSection.swift @@ -9,7 +9,6 @@ import UIKit import Meta import TwidereCore -import TwidereUI enum ListSection: Hashable { case twitter(kind: TwitterListKind) @@ -130,7 +129,7 @@ extension ListSection { ) { switch list { case .twitter(let object): - let metaContent = Meta.convert(from: .plaintext(string: object.name)) + let metaContent = Meta.convert(document: .plaintext(string: object.name)) cell.primaryTextLabel.configure(content: metaContent) cell.accessoryImageView.image = Asset.ObjectTools.lockMiniInline.image.withRenderingMode(.alwaysTemplate) cell.accessoryImageView.contentMode = .scaleAspectFill @@ -138,7 +137,7 @@ extension ListSection { cell.accessoryImageView.isHidden = !object.private cell.accessoryType = .disclosureIndicator case .mastodon(let object): - let metaContent = Meta.convert(from: .plaintext(string: object.title)) + let metaContent = Meta.convert(document: .plaintext(string: object.title)) cell.primaryTextLabel.configure(content: metaContent) cell.accessoryType = .disclosureIndicator } diff --git a/TwidereX/Diffable/Misc/Notification/NotificationSection.swift b/TwidereX/Diffable/Misc/Notification/NotificationSection.swift index b86b68c2..5f628f26 100644 --- a/TwidereX/Diffable/Misc/Notification/NotificationSection.swift +++ b/TwidereX/Diffable/Misc/Notification/NotificationSection.swift @@ -8,8 +8,7 @@ import UIKit -import AppShared -import TwidereUI +import SwiftUI enum NotificationSection: Hashable { case main @@ -20,108 +19,130 @@ extension NotificationSection { struct Configuration { weak var statusViewTableViewCellDelegate: StatusViewTableViewCellDelegate? weak var userViewTableViewCellDelegate: UserViewTableViewCellDelegate? - let statusViewConfigurationContext: StatusView.ConfigurationContext - let userViewConfigurationContext: UserView.ConfigurationContext + let viewLayoutFramePublisher: Published.Publisher? } static func diffableDataSource( tableView: UITableView, context: AppContext, + authContext: AuthContext, configuration: Configuration ) -> UITableViewDiffableDataSource { return UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item in // data source should dispatch in main thread assert(Thread.isMainThread) - + + tableView.register(NotificationTableViewCell.self, forCellReuseIdentifier: String(describing: NotificationTableViewCell.self)) + // tableView.register(UserNotificationStyleTableViewCell.self, forCellReuseIdentifier: String(describing: UserNotificationStyleTableViewCell.self)) + tableView.register(TimelineMiddleLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self)) + tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) + // configure cell with item switch item { case .feed(let record): - return context.managedObjectContext.performAndWait { - guard let feed = record.object(in: context.managedObjectContext) else { - assertionFailure() - return UITableViewCell() + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: NotificationTableViewCell.self), for: indexPath) as! NotificationTableViewCell + cell.statusViewTableViewCellDelegate = configuration.statusViewTableViewCellDelegate + cell.userViewTableViewCellDelegate = configuration.userViewTableViewCellDelegate + context.managedObjectContext.performAndWait { + guard let feed = record.object(in: context.managedObjectContext) else { return } + guard case let .notification(notification) = feed.content else { return } + let viewModel = NotificationView.ViewModel( + notification: notification, + authContext: authContext, + statusViewDelegate: cell, + userViewDelegate: cell, + viewLayoutFramePublisher: configuration.viewLayoutFramePublisher + ) + cell.contentConfiguration = UIHostingConfiguration { + NotificationView(viewModel: viewModel) } - - switch feed.objectContent { - case .status: - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell - StatusSection.setupStatusPollDataSource( - context: context, - statusView: cell.statusView, - configurationContext: configuration.statusViewConfigurationContext - ) - configure( - tableView: tableView, - cell: cell, - viewModel: StatusTableViewCell.ViewModel(value: .feed(feed)), - configuration: configuration - ) - return cell - case .notification(let object): - switch object { - case .mastodon(let notification): - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: UserNotificationStyleTableViewCell.self), for: indexPath) as! UserNotificationStyleTableViewCell - let authenticationContext = context.authenticationService.activeAuthenticationContext - let me = authenticationContext?.user(in: context.managedObjectContext) - let user: UserObject = .mastodon(object: notification.account) - configure( - cell: cell, - viewModel: UserTableViewCell.ViewModel( - user: user, - me: me, - notification: .mastodon(object: notification) - ), - configuration: configuration - ) - return cell - } - case .none: - assertionFailure() - return UITableViewCell() - } - } // end return context.managedObjectContext.performAndWait - - case .feedLoader: - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self), for: indexPath) as! TimelineMiddleLoaderTableViewCell - cell.viewModel.isFetching = true + .margins(.vertical, 0) // remove vertical margins + } return cell - case .bottomLoader: - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell - cell.activityIndicatorView.startAnimating() - return cell + default: + return UITableViewCell() } // end switch - } - } +// switch feed.objectContent { +// case .status: +// let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell +// StatusSection.setupStatusPollDataSource( +// context: context, +// statusView: cell.statusView, +// configurationContext: configuration.statusViewConfigurationContext +// ) +// configure( +// tableView: tableView, +// cell: cell, +// viewModel: StatusTableViewCell.ViewModel(value: .feed(feed)), +// configuration: configuration +// ) +// return cell +// case .notification(let object): +// switch object { +// case .mastodon(let notification): +// let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: UserNotificationStyleTableViewCell.self), for: indexPath) as! UserNotificationStyleTableViewCell +// let authenticationContext = configuration.statusViewConfigurationContext.authContext.authenticationContext +// let me = authenticationContext.user(in: context.managedObjectContext) +// let user: UserObject = .mastodon(object: notification.account) +// configure( +// cell: cell, +// viewModel: UserTableViewCell.ViewModel( +// user: user, +// me: me, +// notification: .mastodon(object: notification) +// ), +// configuration: configuration +// ) +// return cell +// } +// case .none: +// assertionFailure() +// return UITableViewCell() +// } +// } // end return context.managedObjectContext.performAndWait + +// case .feedLoader: +// let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self), for: indexPath) as! TimelineMiddleLoaderTableViewCell +// cell.viewModel.isFetching = true +// return cell +// +// case .bottomLoader: +// let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell +// cell.activityIndicatorView.startAnimating() +// return cell +// } // end switch + } // end return + } // end func } extension NotificationSection { - static func configure( - tableView: UITableView, - cell: StatusTableViewCell, - viewModel: StatusTableViewCell.ViewModel, - configuration: Configuration - ) { - cell.configure( - tableView: tableView, - viewModel: viewModel, - configurationContext: configuration.statusViewConfigurationContext, - delegate: configuration.statusViewTableViewCellDelegate - ) - } +// static func configure( +// tableView: UITableView, +// cell: StatusTableViewCell, +// viewModel: StatusTableViewCell.ViewModel, +// configuration: Configuration +// ) { +// cell.configure( +// tableView: tableView, +// viewModel: viewModel, +// configurationContext: configuration.statusViewConfigurationContext, +// delegate: configuration.statusViewTableViewCellDelegate +// ) +// } - static func configure( - cell: UserTableViewCell, - viewModel: UserTableViewCell.ViewModel, - configuration: Configuration - ) { - cell.configure( - viewModel: viewModel, - configurationContext: configuration.userViewConfigurationContext, - delegate: configuration.userViewTableViewCellDelegate - ) - } +// static func configure( +// cell: UserTableViewCell, +// viewModel: UserTableViewCell.ViewModel, +// configuration: Configuration +// ) { +// cell.configure( +// viewModel: viewModel, +// configurationContext: configuration.userViewConfigurationContext, +// delegate: configuration.userViewTableViewCellDelegate +// ) +// } } diff --git a/TwidereX/Diffable/Misc/Search/SearchSection.swift b/TwidereX/Diffable/Misc/Search/SearchSection.swift index cf4c0495..315934d9 100644 --- a/TwidereX/Diffable/Misc/Search/SearchSection.swift +++ b/TwidereX/Diffable/Misc/Search/SearchSection.swift @@ -7,8 +7,10 @@ // import UIKit +import SwiftUI import Meta import TwidereCore +import TwidereUI enum SearchSection: Hashable, CaseIterable { case history @@ -17,7 +19,9 @@ enum SearchSection: Hashable, CaseIterable { extension SearchSection { - struct Configuration { } + struct Configuration { + let viewLayoutFramePublisher: Published.Publisher? + } static func diffableDataSource( tableView: UITableView, @@ -41,7 +45,13 @@ extension SearchSection { return cell case .trend(let object): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TrendTableViewCell.self), for: indexPath) as! TrendTableViewCell - configure(cell: cell, object: object) + let trendViewModel = TrendView.ViewModel( + object: object, + viewLayoutFramePublisher: configuration.viewLayoutFramePublisher + ) + cell.contentConfiguration = UIHostingConfiguration { + TrendView(viewModel: trendViewModel) + } return cell case .loader: let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell @@ -76,42 +86,42 @@ extension SearchSection { ) { switch object { case .twitter(let history): - let metaContent = Meta.convert(from: .plaintext(string: history.name)) + let metaContent = Meta.convert(document: .plaintext(string: history.name)) cell.primaryTextLabel.configure(content: metaContent) case .mastodon(let history): - let metaContent = Meta.convert(from: .plaintext(string: history.query)) + let metaContent = Meta.convert(document: .plaintext(string: history.query)) cell.primaryTextLabel.configure(content: metaContent) } } - private static func configure( - cell: TrendTableViewCell, - object: TrendObject - ) { - switch object { - case .twitter(let trend): - let metaContent = Meta.convert(from: .plaintext(string: trend.name)) - cell.primaryLabel.configure(content: metaContent) - cell.accessoryType = .disclosureIndicator - case .mastodon(let tag): - let metaContent = Meta.convert(from: .plaintext(string: "#" + tag.name)) - - cell.primaryLabel.configure(content: metaContent) - cell.secondaryLabel.text = L10n.Scene.Trends.accounts(tag.talkingPeopleCount ?? 0) - cell.setSecondaryLabelDisplay() - - cell.supplementaryLabel.text = tag.history?.first?.uses ?? " " - cell.setSupplementaryLabelDisplay() - - cell.lineChartView.data = (tag.history ?? []) - .sorted(by: { $0.day < $1.day }) // latest last - .map { entry in - guard let point = Int(entry.accounts) else { - return .zero - } - return CGFloat(point) - } - cell.setLineChartViewDisplay() - } - } +// private static func configure( +// cell: TrendTableViewCell, +// object: TrendObject +// ) { +// switch object { +// case .twitter(let trend): +// let metaContent = Meta.convert(document: .plaintext(string: trend.name)) +// cell.primaryLabel.configure(content: metaContent) +// cell.accessoryType = .disclosureIndicator +// case .mastodon(let tag): +// let metaContent = Meta.convert(document: .plaintext(string: "#" + tag.name)) +// +// cell.primaryLabel.configure(content: metaContent) +// cell.secondaryLabel.text = L10n.Scene.Trends.accounts(tag.talkingPeopleCount ?? 0) +// cell.setSecondaryLabelDisplay() +// +// cell.supplementaryLabel.text = tag.history?.first?.uses ?? " " +// cell.setSupplementaryLabelDisplay() +// +// cell.lineChartView.data = (tag.history ?? []) +// .sorted(by: { $0.day < $1.day }) // latest last +// .map { entry in +// guard let point = Int(entry.accounts) else { +// return .zero +// } +// return CGFloat(point) +// } +// cell.setLineChartViewDisplay() +// } +// } } diff --git a/TwidereX/Diffable/Misc/Sidebar/SidebarItem.swift b/TwidereX/Diffable/Misc/Sidebar/SidebarItem.swift index 40e9a1df..dfba6ad7 100644 --- a/TwidereX/Diffable/Misc/Sidebar/SidebarItem.swift +++ b/TwidereX/Diffable/Misc/Sidebar/SidebarItem.swift @@ -15,6 +15,7 @@ enum SidebarItem: Hashable { case federated // Mastodon only case messages case likes + case history case lists case trends case drafts @@ -29,6 +30,7 @@ extension SidebarItem { case .federated: return L10n.Scene.Federated.title case .messages: return L10n.Scene.Messages.title case .likes: return L10n.Scene.Likes.title + case .history: return L10n.Scene.History.title case .lists: return L10n.Scene.Lists.title case .trends: return L10n.Scene.Trends.title case .drafts: return L10n.Scene.Drafts.title @@ -42,6 +44,7 @@ extension SidebarItem { case .federated: return Asset.ObjectTools.globe.image.withRenderingMode(.alwaysTemplate) case .messages: return Asset.Communication.mail.image.withRenderingMode(.alwaysTemplate) case .likes: return Asset.Health.heart.image.withRenderingMode(.alwaysTemplate) + case .history: return Asset.Arrows.clockArrowCirclepath.image.withRenderingMode(.alwaysTemplate) case .lists: return Asset.TextFormatting.listBullet.image.withRenderingMode(.alwaysTemplate) case .trends: return Asset.Arrows.trendingUp.image.withRenderingMode(.alwaysTemplate) case .drafts: return Asset.ObjectTools.note.image.withRenderingMode(.alwaysTemplate) diff --git a/TwidereX/Diffable/Misc/TabBar/TabBarItem.swift b/TwidereX/Diffable/Misc/TabBar/TabBarItem.swift index d4934b6e..7558aef4 100644 --- a/TwidereX/Diffable/Misc/TabBar/TabBarItem.swift +++ b/TwidereX/Diffable/Misc/TabBar/TabBarItem.swift @@ -9,10 +9,10 @@ import UIKit import TwidereAsset import TwidereLocalization -import TwidereUI enum TabBarItem: Int, Hashable { case home + case homeList case notification case search case me @@ -20,6 +20,7 @@ enum TabBarItem: Int, Hashable { case federated // Mastodon only case messages case likes + case history case lists case trends case drafts @@ -34,24 +35,27 @@ extension TabBarItem { var title: String { switch self { - case .home: return L10n.Scene.Timeline.title - case .notification: return L10n.Scene.Notification.title - case .search: return L10n.Scene.Search.title - case .me: return L10n.Scene.Profile.title - case .local: return L10n.Scene.Local.title - case .federated: return L10n.Scene.Federated.title - case .messages: return L10n.Scene.Messages.title - case .likes: return L10n.Scene.Likes.title - case .lists: return L10n.Scene.Lists.title - case .trends: return L10n.Scene.Trends.title - case .drafts: return L10n.Scene.Drafts.title - case .settings: return L10n.Scene.Settings.title + case .home: return L10n.Scene.Timeline.title + case .homeList: return L10n.Scene.Timeline.title + case .notification: return L10n.Scene.Notification.title + case .search: return L10n.Scene.Search.title + case .me: return L10n.Scene.Profile.title + case .local: return L10n.Scene.Local.title + case .federated: return L10n.Scene.Federated.title + case .messages: return L10n.Scene.Messages.title + case .likes: return L10n.Scene.Likes.title + case .history: return L10n.Scene.History.title + case .lists: return L10n.Scene.Lists.title + case .trends: return L10n.Scene.Trends.title + case .drafts: return L10n.Scene.Drafts.title + case .settings: return L10n.Scene.Settings.title } } var image: UIImage { switch self { case .home: return Asset.ObjectTools.house.image.withRenderingMode(.alwaysTemplate) + case .homeList: return Asset.ObjectTools.house.image.withRenderingMode(.alwaysTemplate) case .notification: return Asset.ObjectTools.bell.image.withRenderingMode(.alwaysTemplate) case .search: return Asset.ObjectTools.magnifyingglass.image.withRenderingMode(.alwaysTemplate) case .me: return Asset.Human.person.image.withRenderingMode(.alwaysTemplate) @@ -59,6 +63,7 @@ extension TabBarItem { case .federated: return Asset.ObjectTools.globe.image.withRenderingMode(.alwaysTemplate) case .messages: return Asset.Communication.mail.image.withRenderingMode(.alwaysTemplate) case .likes: return Asset.Health.heart.image.withRenderingMode(.alwaysTemplate) + case .history: return Asset.Arrows.clockArrowCirclepath.image.withRenderingMode(.alwaysTemplate) case .lists: return Asset.TextFormatting.listBullet.image.withRenderingMode(.alwaysTemplate) case .trends: return Asset.Arrows.trendingUp.image.withRenderingMode(.alwaysTemplate) case .drafts: return Asset.ObjectTools.note.image.withRenderingMode(.alwaysTemplate) @@ -80,45 +85,70 @@ extension TabBarItem { } extension TabBarItem { - func viewController(context: AppContext, coordinator: SceneCoordinator) -> UIViewController { + func viewController(context: AppContext, coordinator: SceneCoordinator, authContext: AuthContext) -> UIViewController { let viewController: UIViewController switch self { case .home: let _viewController = HomeTimelineViewController() - _viewController.viewModel = HomeTimelineViewModel(context: context) + _viewController.viewModel = HomeTimelineViewModel(context: context, authContext: authContext) + viewController = _viewController + case .homeList: + let _viewController = HomeListStatusTimelineViewController() + _viewController.viewModel = HomeListStatusTimelineViewModel(context: context, authContext: authContext) viewController = _viewController case .notification: let _viewController = NotificationViewController() + _viewController.viewModel = NotificationViewModel(context: context, authContext: authContext, coordinator: coordinator) viewController = _viewController case .search: let _viewController = SearchViewController() - _viewController.viewModel = SearchViewModel(context: context) + _viewController.viewModel = SearchViewModel(context: context, authContext: authContext) viewController = _viewController case .me: let _viewController = ProfileViewController() - let profileViewModel = MeProfileViewModel(context: context) + let profileViewModel = MeProfileViewModel(context: context, authContext: authContext) _viewController.viewModel = profileViewModel viewController = _viewController case .local: let _viewController = FederatedTimelineViewController() - _viewController.viewModel = FederatedTimelineViewModel(context: context, isLocal: true) + _viewController.viewModel = FederatedTimelineViewModel(context: context, authContext: authContext, isLocal: true) viewController = _viewController case .federated: let _viewController = FederatedTimelineViewController() - _viewController.viewModel = FederatedTimelineViewModel(context: context, isLocal: false) + _viewController.viewModel = FederatedTimelineViewModel(context: context, authContext: authContext, isLocal: false) viewController = _viewController case .messages: fatalError() case .likes: let _viewController = UserLikeTimelineViewController() - _viewController.viewModel = MeLikeTimelineViewModel(context: context) + _viewController.viewModel = UserLikeTimelineViewModel( + context: context, + authContext: authContext, + timelineContext: .init( + timelineKind: .like, + protected: { + guard let user = authContext.authenticationContext.user(in: context.managedObjectContext) else { return false } + return user.protected + }(), + userIdentifier: authContext.authenticationContext.userIdentifier + ) + ) + _viewController.viewModel.isFloatyButtonDisplay = false + viewController = _viewController + case .history: + let _viewController = HistoryViewController() + _viewController.viewModel = HistoryViewModel( + context: context, + coordinator: coordinator, + authContext: authContext + ) viewController = _viewController case .lists: - guard let me = context.authenticationService.activeAuthenticationContext?.user(in: context.managedObjectContext)?.asRecord else { + guard let me = authContext.authenticationContext.user(in: context.managedObjectContext)?.asRecord else { return AdaptiveStatusBarStyleNavigationController(rootViewController: UIViewController()) } let _viewController = CompositeListViewController() - _viewController.viewModel = CompositeListViewModel(context: context, kind: .lists(me)) + _viewController.viewModel = CompositeListViewModel(context: context, authContext: authContext, kind: .lists(me)) viewController = _viewController case .trends: fatalError() diff --git a/TwidereX/Diffable/Status/StatusItem.swift b/TwidereX/Diffable/Status/StatusItem.swift index 48e63f0a..9d6b4d81 100644 --- a/TwidereX/Diffable/Status/StatusItem.swift +++ b/TwidereX/Diffable/Status/StatusItem.swift @@ -10,58 +10,17 @@ import Foundation import CoreDataStack import TwidereCore -enum StatusItem: Hashable { +enum StatusItem: Hashable, DifferenceItem { case feed(record: ManagedObjectRecord) case feedLoader(record: ManagedObjectRecord) case status(StatusRecord) - case thread(Thread) case topLoader case bottomLoader -} - -extension StatusItem { - enum Thread: Hashable { - case root(context: Context) - case reply(context: Context) - case leaf(context: Context) - - public var statusRecord: StatusRecord { - switch self { - case .root(let threadContext), - .reply(let threadContext), - .leaf(let threadContext): - return threadContext.status - } - } - } -} - -extension StatusItem.Thread { - class Context: Hashable { - let status: StatusRecord - var displayUpperConversationLink: Bool - var displayBottomConversationLink: Bool - - init( - status: StatusRecord, - displayUpperConversationLink: Bool = false, - displayBottomConversationLink: Bool = false - ) { - self.status = status - self.displayUpperConversationLink = displayUpperConversationLink - self.displayBottomConversationLink = displayBottomConversationLink - } - - static func == (lhs: StatusItem.Thread.Context, rhs: StatusItem.Thread.Context) -> Bool { - return lhs.status == rhs.status - && lhs.displayUpperConversationLink == rhs.displayUpperConversationLink - && lhs.displayBottomConversationLink == rhs.displayBottomConversationLink - } - - func hash(into hasher: inout Hasher) { - hasher.combine(status) - hasher.combine(displayUpperConversationLink) - hasher.combine(displayBottomConversationLink) + + var isTransient: Bool { + switch self { + case .topLoader, .bottomLoader: return true + default: return false } } } diff --git a/TwidereX/Diffable/Status/StatusMediaGallerySection.swift b/TwidereX/Diffable/Status/StatusMediaGallerySection.swift index 3ecfa2ca..4e527a56 100644 --- a/TwidereX/Diffable/Status/StatusMediaGallerySection.swift +++ b/TwidereX/Diffable/Status/StatusMediaGallerySection.swift @@ -7,6 +7,7 @@ // import UIKit +import SwiftUI import CoreData import CoreDataStack @@ -28,17 +29,24 @@ extension StatusMediaGallerySection { ) -> UICollectionViewDiffableDataSource { let statusRecordCell = UICollectionView.CellRegistration { cell, indexPath, record in + cell.delegate = configuration.statusMediaGalleryCollectionCellDelegate context.managedObjectContext.performAndWait { guard let status = record.object(in: context.managedObjectContext) else { assertionFailure() return } - configure( - collectionView: collectionView, - cell: cell, - status: status, - configuration: configuration - ) + let items = MediaView.ViewModel.viewModels(from: status) + let viewModel = MediaStackContainerView.ViewModel(items: items) + cell.contentConfiguration = UIHostingConfiguration { + MediaStackContainerView( + viewModel: viewModel, + handler: { [weak cell] mediaViewModel, _ in + guard let cell = cell else { return } + configuration.statusMediaGalleryCollectionCellDelegate?.statusMediaGalleryCollectionCell(cell, mediaStackContainerViewModel: viewModel, didSelectMediaView: mediaViewModel) + } + ) + } + .margins(.vertical, 0) // remove vertical margins } } @@ -63,14 +71,14 @@ extension StatusMediaGallerySection { extension StatusMediaGallerySection { - static func configure( - collectionView: UICollectionView, - cell: StatusMediaGalleryCollectionCell, - status: StatusObject, - configuration: Configuration - ) { - cell.configure(status: status) - cell.delegate = configuration.statusMediaGalleryCollectionCellDelegate - } +// static func configure( +// collectionView: UICollectionView, +// cell: StatusMediaGalleryCollectionCell, +// status: StatusObject, +// configuration: Configuration +// ) { +// cell.configure(status: status) +// cell.delegate = configuration.statusMediaGalleryCollectionCellDelegate +// } } diff --git a/TwidereX/Diffable/Status/StatusSection.swift b/TwidereX/Diffable/Status/StatusSection.swift index 91c8059e..bb90adba 100644 --- a/TwidereX/Diffable/Status/StatusSection.swift +++ b/TwidereX/Diffable/Status/StatusSection.swift @@ -9,14 +9,13 @@ import os.log import UIKit import Combine +import SwiftUI import CoreData import CoreDataStack import MetaTextKit -import TwidereUI -import AppShared import TwitterSDK -enum StatusSection: Int, Hashable { +enum StatusSection: Hashable { case main case footer } @@ -28,12 +27,13 @@ extension StatusSection { struct Configuration { weak var statusViewTableViewCellDelegate: StatusViewTableViewCellDelegate? weak var timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate? - let statusViewConfigurationContext: StatusView.ConfigurationContext + let viewLayoutFramePublisher: Published.Publisher? } static func diffableDataSource( tableView: UITableView, context: AppContext, + authContext: AuthContext, configuration: Configuration ) -> UITableViewDiffableDataSource { tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self)) @@ -48,73 +48,55 @@ extension StatusSection { switch item { case .feed(let record): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell - setupStatusPollDataSource( - context: context, - statusView: cell.statusView, - configurationContext: configuration.statusViewConfigurationContext - ) + cell.statusViewTableViewCellDelegate = configuration.statusViewTableViewCellDelegate context.managedObjectContext.performAndWait { guard let feed = record.object(in: context.managedObjectContext) else { return } - configure( - tableView: tableView, - cell: cell, - viewModel: StatusTableViewCell.ViewModel(value: .feed(feed)), - configuration: configuration - ) - } - return cell - case .feedLoader(let record): - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self), for: indexPath) as! TimelineMiddleLoaderTableViewCell - context.managedObjectContext.performAndWait { - guard let feed = record.object(in: context.managedObjectContext) else { return } - configure( - cell: cell, + let _viewModel = StatusView.ViewModel( feed: feed, - configuration: configuration + authContext: authContext, + delegate: cell, + viewLayoutFramePublisher: configuration.viewLayoutFramePublisher ) + + guard let viewModel = _viewModel else { return } + cell.contentConfiguration = UIHostingConfiguration { + StatusView(viewModel: viewModel) + } + .margins(.vertical, 0) // remove vertical margins } return cell - case .status(let status): + case .status(let record): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell - setupStatusPollDataSource( - context: context, - statusView: cell.statusView, - configurationContext: configuration.statusViewConfigurationContext - ) + cell.statusViewTableViewCellDelegate = configuration.statusViewTableViewCellDelegate context.managedObjectContext.performAndWait { - switch status { - case .twitter(let record): - guard let status = record.object(in: context.managedObjectContext) else { return } - configure( - tableView: tableView, - cell: cell, - viewModel: StatusTableViewCell.ViewModel(value: .twitterStatus(status)), - configuration: configuration - ) - case .mastodon(let record): - guard let status = record.object(in: context.managedObjectContext) else { return } - configure( - tableView: tableView, - cell: cell, - viewModel: StatusTableViewCell.ViewModel(value: .mastodonStatus(status)), - configuration: configuration - ) - } // end switch + guard let status = record.object(in: context.managedObjectContext) else { return } + let viewModel = StatusView.ViewModel( + status: status, + authContext: authContext, + delegate: cell, + viewLayoutFramePublisher: cell.$viewLayoutaFrame + ) + + cell.contentConfiguration = UIHostingConfiguration { + StatusView(viewModel: viewModel) + } + .margins(.vertical, 0) // remove vertical margins } return cell - case .thread(let thread): - return StatusSection.dequeueConfiguredReusableCell( - context: context, - tableView: tableView, - indexPath: indexPath, - configuration: ThreadCellRegistrationConfiguration( - thread: thread, - configuration: configuration - ) - ) - + case .feedLoader(let record): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self), for: indexPath) as! TimelineMiddleLoaderTableViewCell +// context.managedObjectContext.performAndWait { +// guard let feed = record.object(in: context.managedObjectContext) else { return } +// configure( +// cell: cell, +// feed: feed, +// configuration: configuration +// ) +// } + return cell + case .topLoader: let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell cell.activityIndicatorView.startAnimating() @@ -131,163 +113,163 @@ extension StatusSection { extension StatusSection { - struct ThreadCellRegistrationConfiguration { - let thread: StatusItem.Thread - let configuration: Configuration - } - - static func dequeueConfiguredReusableCell( - context: AppContext, - tableView: UITableView, - indexPath: IndexPath, - configuration: ThreadCellRegistrationConfiguration - ) -> UITableViewCell { - let managedObjectContext = context.managedObjectContext - - let configurationContext = configuration.configuration.statusViewConfigurationContext - - switch configuration.thread { - case .root(let threadContext): - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusThreadRootTableViewCell.self), for: indexPath) as! StatusThreadRootTableViewCell - setupStatusPollDataSource( - context: context, - statusView: cell.statusView, - configurationContext: configurationContext - ) - managedObjectContext.performAndWait { - guard let status = threadContext.status.object(in: managedObjectContext) else { return } - cell.configure( - tableView: tableView, - viewModel: StatusThreadRootTableViewCell.ViewModel(value: .statusObject(status)), - configurationContext: configurationContext, - delegate: configuration.configuration.statusViewTableViewCellDelegate - ) - } - if threadContext.displayUpperConversationLink { - cell.setConversationLinkLineViewDisplay() - } - return cell - case .reply(let threadContext), - .leaf(let threadContext): - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell - setupStatusPollDataSource( - context: context, - statusView: cell.statusView, - configurationContext: configurationContext - ) - managedObjectContext.performAndWait { - guard let status = threadContext.status.object(in: managedObjectContext) else { return } - cell.configure( - tableView: tableView, - viewModel: StatusTableViewCell.ViewModel(value: .statusObject(status)), - configurationContext: configurationContext, - delegate: configuration.configuration.statusViewTableViewCellDelegate - ) - } - if threadContext.displayUpperConversationLink { - cell.setTopConversationLinkLineViewDisplay() - } - if threadContext.displayBottomConversationLink { - cell.setBottomConversationLinkLineViewDisplay() - } - return cell - } - } +// struct ThreadCellRegistrationConfiguration { +// let thread: StatusItem.Thread +// let configuration: Configuration +// } +// +// static func dequeueConfiguredReusableCell( +// context: AppContext, +// tableView: UITableView, +// indexPath: IndexPath, +// configuration: ThreadCellRegistrationConfiguration +// ) -> UITableViewCell { +// let managedObjectContext = context.managedObjectContext +// +// let configurationContext = configuration.configuration.statusViewConfigurationContext +// +// switch configuration.thread { +// case .root(let threadContext): +// let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusThreadRootTableViewCell.self), for: indexPath) as! StatusThreadRootTableViewCell +// setupStatusPollDataSource( +// context: context, +// statusView: cell.statusView, +// configurationContext: configurationContext +// ) +// managedObjectContext.performAndWait { +// guard let status = threadContext.status.object(in: managedObjectContext) else { return } +// cell.configure( +// tableView: tableView, +// viewModel: StatusThreadRootTableViewCell.ViewModel(value: .statusObject(status)), +// configurationContext: configurationContext, +// delegate: configuration.configuration.statusViewTableViewCellDelegate +// ) +// } +// if threadContext.displayUpperConversationLink { +// cell.setConversationLinkLineViewDisplay() +// } +// return cell +// case .reply(let threadContext), +// .leaf(let threadContext): +// let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell +// setupStatusPollDataSource( +// context: context, +// statusView: cell.statusView, +// configurationContext: configurationContext +// ) +// managedObjectContext.performAndWait { +// guard let status = threadContext.status.object(in: managedObjectContext) else { return } +// cell.configure( +// tableView: tableView, +// viewModel: StatusTableViewCell.ViewModel(value: .statusObject(status)), +// configurationContext: configurationContext, +// delegate: configuration.configuration.statusViewTableViewCellDelegate +// ) +// } +// if threadContext.displayUpperConversationLink { +// cell.setTopConversationLinkLineViewDisplay() +// } +// if threadContext.displayBottomConversationLink { +// cell.setBottomConversationLinkLineViewDisplay() +// } +// return cell +// } +// } - public static func setupStatusPollDataSource( - context: AppContext, - statusView: StatusView, - configurationContext: PollOptionView.ConfigurationContext - ) { - let managedObjectContext = context.managedObjectContext - statusView.pollTableViewDiffableDataSource = UITableViewDiffableDataSource(tableView: statusView.pollTableView) { tableView, indexPath, item in - switch item { - case .option(let record): - // Fix cell reuse animation issue - let cell: PollOptionTableViewCell = { - let _cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PollOptionTableViewCell.self) + "@\(indexPath.row)#\(indexPath.section)") as? PollOptionTableViewCell - _cell?.prepareForReuse() - return _cell ?? PollOptionTableViewCell() - }() - - managedObjectContext.performAndWait { - guard let option = record.object(in: managedObjectContext) else { - assertionFailure() - return - } - cell.optionView.configure( - pollOption: option, - configurationContext: configurationContext - ) - - // trigger update if needs - // check is the first option in poll to trigger update poll only once - if option.index == 0, option.poll.needsUpdate { - let authenticationContext = context.authenticationService.activeAuthenticationContext - switch (option, authenticationContext) { - case (.twitter(let object), .twitter(let authenticationContext)): - let status: ManagedObjectRecord = .init(objectID: object.poll.status.objectID) - Task { [weak context] in - guard let context = context else { return } - let statusIDs: [Twitter.Entity.V2.Tweet.ID] = await context.managedObjectContext.perform { - guard let _status = status.object(in: context.managedObjectContext) else { return [] } - let status = _status.repost ?? _status - return [status.id] - } - _ = try await context.apiService.twitterStatus( - statusIDs: statusIDs, - authenticationContext: authenticationContext - ) - } // end Task - case (.mastodon(let object), .mastodon(let authenticationContext)): - let status: ManagedObjectRecord = .init(objectID: object.poll.status.objectID) - Task { [weak context] in - guard let context = context else { return } - _ = try await context.apiService.viewMastodonStatusPoll( - status: status, - authenticationContext: authenticationContext - ) - } // end Task - default: - assertionFailure() - } - } - } // end managedObjectContext.performAndWait - return cell - } - } - var _snapshot = NSDiffableDataSourceSnapshot() - _snapshot.appendSections([.main]) - statusView.pollTableViewDiffableDataSource?.applySnapshotUsingReloadData(_snapshot) - } +// public static func setupStatusPollDataSource( +// context: AppContext, +// statusView: StatusView, +// configurationContext: PollOptionView.ConfigurationContext +// ) { +// let managedObjectContext = context.managedObjectContext +// statusView.pollTableViewDiffableDataSource = UITableViewDiffableDataSource(tableView: statusView.pollTableView) { tableView, indexPath, item in +// switch item { +// case .option(let record): +// // Fix cell reuse animation issue +// let cell: PollOptionTableViewCell = { +// let _cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PollOptionTableViewCell.self) + "@\(indexPath.row)#\(indexPath.section)") as? PollOptionTableViewCell +// _cell?.prepareForReuse() +// return _cell ?? PollOptionTableViewCell() +// }() +// +// managedObjectContext.performAndWait { +// guard let option = record.object(in: managedObjectContext) else { +// assertionFailure() +// return +// } +// cell.optionView.configure( +// pollOption: option, +// configurationContext: configurationContext +// ) +// +// // trigger update if needs +// // check is the first option in poll to trigger update poll only once +// if option.index == 0, option.poll.needsUpdate { +// let authenticationContext = configurationContext.authContext.authenticationContext +// switch (option, authenticationContext) { +// case (.twitter(let object), .twitter(let authenticationContext)): +// let status: ManagedObjectRecord = .init(objectID: object.poll.status.objectID) +// Task { [weak context] in +// guard let context = context else { return } +// let statusIDs: [Twitter.Entity.V2.Tweet.ID] = await context.managedObjectContext.perform { +// guard let _status = status.object(in: context.managedObjectContext) else { return [] } +// let status = _status.repost ?? _status +// return [status.id] +// } +// _ = try await context.apiService.twitterStatus( +// statusIDs: statusIDs, +// authenticationContext: authenticationContext +// ) +// } // end Task +// case (.mastodon(let object), .mastodon(let authenticationContext)): +// let status: ManagedObjectRecord = .init(objectID: object.poll.status.objectID) +// Task { [weak context] in +// guard let context = context else { return } +// _ = try await context.apiService.viewMastodonStatusPoll( +// status: status, +// authenticationContext: authenticationContext +// ) +// } // end Task +// default: +// assertionFailure() +// } +// } +// } // end managedObjectContext.performAndWait +// return cell +// } +// } +// var _snapshot = NSDiffableDataSourceSnapshot() +// _snapshot.appendSections([.main]) +// statusView.pollTableViewDiffableDataSource?.applySnapshotUsingReloadData(_snapshot) +// } } extension StatusSection { - static func configure( - tableView: UITableView, - cell: StatusTableViewCell, - viewModel: StatusTableViewCell.ViewModel, - configuration: Configuration - ) { - cell.configure( - tableView: tableView, - viewModel: viewModel, - configurationContext: configuration.statusViewConfigurationContext, - delegate: configuration.statusViewTableViewCellDelegate - ) - } - - static func configure( - cell: TimelineMiddleLoaderTableViewCell, - feed: Feed, - configuration: Configuration - ) { - cell.configure( - feed: feed, - delegate: configuration.timelineMiddleLoaderTableViewCellDelegate - ) - } +// static func configure( +// tableView: UITableView, +// cell: StatusTableViewCell, +// viewModel: StatusTableViewCell.ViewModel, +// configuration: Configuration +// ) { +// cell.configure( +// tableView: tableView, +// viewModel: viewModel, +// configurationContext: configuration.statusViewConfigurationContext, +// delegate: configuration.statusViewTableViewCellDelegate +// ) +// } +// +// static func configure( +// cell: TimelineMiddleLoaderTableViewCell, +// feed: Feed, +// configuration: Configuration +// ) { +// cell.configure( +// feed: feed, +// delegate: configuration.timelineMiddleLoaderTableViewCellDelegate +// ) +// } } diff --git a/TwidereX/Diffable/User/UserItem.swift b/TwidereX/Diffable/User/UserItem.swift index b26f468f..312cf338 100644 --- a/TwidereX/Diffable/User/UserItem.swift +++ b/TwidereX/Diffable/User/UserItem.swift @@ -9,10 +9,9 @@ import Foundation import CoreDataStack import TwidereCore -import TwidereUI enum UserItem: Hashable { case authenticationIndex(record: ManagedObjectRecord) - case user(record: UserRecord, style: UserView.Style) + case user(record: UserRecord, kind: UserView.ViewModel.Kind) case bottomLoader } diff --git a/TwidereX/Diffable/User/UserSection.swift b/TwidereX/Diffable/User/UserSection.swift index 291d4c66..a5da70fc 100644 --- a/TwidereX/Diffable/User/UserSection.swift +++ b/TwidereX/Diffable/User/UserSection.swift @@ -7,9 +7,9 @@ // import UIKit +import SwiftUI import Combine import CoreDataStack -import TwidereUI enum UserSection { case main @@ -19,114 +19,103 @@ extension UserSection { struct Configuration { weak var userViewTableViewCellDelegate: UserViewTableViewCellDelegate? - let userViewConfigurationContext: UserView.ConfigurationContext } static func diffableDataSource( tableView: UITableView, context: AppContext, + authContext: AuthContext, configuration: Configuration ) -> UITableViewDiffableDataSource { - let cellTypes = [ - UserAccountStyleTableViewCell.self, - UserRelationshipStyleTableViewCell.self, - UserFriendshipStyleTableViewCell.self, - UserMentionPickStyleTableViewCell.self, - UserNotificationStyleTableViewCell.self, - UserListMemberStyleTableViewCell.self, - UserAddListMemberStyleTableViewCell.self, - TimelineBottomLoaderTableViewCell.self, - ] - - cellTypes.forEach { type in - tableView.register(type, forCellReuseIdentifier: String(describing: type)) - } - + tableView.register(UserTableViewCell.self, forCellReuseIdentifier: String(describing: UserTableViewCell.self)) + tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) + return UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item in // data source should dispatch in main thread assert(Thread.isMainThread) + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: UserTableViewCell.self), for: indexPath) as! UserTableViewCell + cell.userViewTableViewCellDelegate = configuration.userViewTableViewCellDelegate + // configure cell with item switch item { case .authenticationIndex(let record): - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: UserAccountStyleTableViewCell.self), for: indexPath) as! UserAccountStyleTableViewCell context.managedObjectContext.performAndWait { guard let authenticationIndex = record.object(in: context.managedObjectContext) else { return } guard let me = authenticationIndex.user else { return } - let viewModel = UserTableViewCell.ViewModel( + let _viewModel = UserView.ViewModel( user: me, - me: me, - notification: nil - ) - configure( - cell: cell, - viewModel: viewModel, - configuration: configuration + authContext: authContext, + kind: .account, + delegate: cell ) + guard let viewModel = _viewModel else { return } + cell.contentConfiguration = UIHostingConfiguration { + UserView(viewModel: viewModel) + } + .margins(.vertical, 0) // remove vertical margins } - return cell - case .user(let record, let style): - let cell = dequeueReusableCell(tableView: tableView, indexPath: indexPath, style: style) + case .user(let record, let kind): context.managedObjectContext.performAndWait { guard let user = record.object(in: context.managedObjectContext) else { return } - let authenticationContext = context.authenticationService.activeAuthenticationContext - let me = authenticationContext?.user(in: context.managedObjectContext) - let viewModel = UserTableViewCell.ViewModel( + let _viewModel = UserView.ViewModel( user: user, - me: me, - notification: nil - ) - configure( - cell: cell, - viewModel: viewModel, - configuration: configuration + authContext: authContext, + kind: kind, + delegate: cell ) + guard let viewModel = _viewModel else { return } + cell.contentConfiguration = UIHostingConfiguration { + UserView(viewModel: viewModel) + } + .margins(.vertical, 0) // remove vertical margins } - return cell case .bottomLoader: let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell cell.activityIndicatorView.startAnimating() return cell - } + } // end switch + + return cell } - } + } // end func } extension UserSection { - static func dequeueReusableCell( - tableView: UITableView, - indexPath: IndexPath, - style: UserView.Style - ) -> UserTableViewCell { - switch style { - case .account: - return tableView.dequeueReusableCell(withIdentifier: String(describing: UserAccountStyleTableViewCell.self), for: indexPath) as! UserAccountStyleTableViewCell - case .relationship: - return tableView.dequeueReusableCell(withIdentifier: String(describing: UserRelationshipStyleTableViewCell.self), for: indexPath) as! UserRelationshipStyleTableViewCell - case .friendship: - return tableView.dequeueReusableCell(withIdentifier: String(describing: UserFriendshipStyleTableViewCell.self), for: indexPath) as! UserFriendshipStyleTableViewCell - case .notification: - return tableView.dequeueReusableCell(withIdentifier: String(describing: UserNotificationStyleTableViewCell.self), for: indexPath) as! UserNotificationStyleTableViewCell - case .mentionPick: - return tableView.dequeueReusableCell(withIdentifier: String(describing: UserMentionPickStyleTableViewCell.self), for: indexPath) as! UserMentionPickStyleTableViewCell - case .listMember: - return tableView.dequeueReusableCell(withIdentifier: String(describing: UserListMemberStyleTableViewCell.self), for: indexPath) as! UserListMemberStyleTableViewCell - case .addListMember: - return tableView.dequeueReusableCell(withIdentifier: String(describing: UserAddListMemberStyleTableViewCell.self), for: indexPath) as! UserAddListMemberStyleTableViewCell - } - } +// static func dequeueReusableCell( +// tableView: UITableView, +// indexPath: IndexPath, +// style: UserView.Style +// ) -> UserTableViewCell { +// switch style { +// case .account: +// return tableView.dequeueReusableCell(withIdentifier: String(describing: UserAccountStyleTableViewCell.self), for: indexPath) as! UserAccountStyleTableViewCell +// case .relationship: +// return tableView.dequeueReusableCell(withIdentifier: String(describing: UserRelationshipStyleTableViewCell.self), for: indexPath) as! UserRelationshipStyleTableViewCell +// case .friendship: +// return tableView.dequeueReusableCell(withIdentifier: String(describing: UserFriendshipStyleTableViewCell.self), for: indexPath) as! UserFriendshipStyleTableViewCell +// case .notification: +// return tableView.dequeueReusableCell(withIdentifier: String(describing: UserNotificationStyleTableViewCell.self), for: indexPath) as! UserNotificationStyleTableViewCell +// case .mentionPick: +// return tableView.dequeueReusableCell(withIdentifier: String(describing: UserMentionPickStyleTableViewCell.self), for: indexPath) as! UserMentionPickStyleTableViewCell +// case .listMember: +// return tableView.dequeueReusableCell(withIdentifier: String(describing: UserListMemberStyleTableViewCell.self), for: indexPath) as! UserListMemberStyleTableViewCell +// case .addListMember: +// return tableView.dequeueReusableCell(withIdentifier: String(describing: UserAddListMemberStyleTableViewCell.self), for: indexPath) as! UserAddListMemberStyleTableViewCell +// } +// } - static func configure( - cell: UserTableViewCell, - viewModel: UserTableViewCell.ViewModel, - configuration: Configuration - ) { - cell.configure( - viewModel: viewModel, - configurationContext: configuration.userViewConfigurationContext, - delegate: configuration.userViewTableViewCellDelegate - ) - } +// static func configure( +// cell: UserTableViewCell, +// viewModel: UserTableViewCell.ViewModel, +// configuration: Configuration +// ) { +// cell.configure( +// viewModel: viewModel, +// configurationContext: configuration.userViewConfigurationContext, +// delegate: configuration.userViewTableViewCellDelegate +// ) +// } } diff --git a/TwidereX/Extension/AVPlayer.swift b/TwidereX/Extension/AVPlayer.swift index 208eda49..33f12b11 100644 --- a/TwidereX/Extension/AVPlayer.swift +++ b/TwidereX/Extension/AVPlayer.swift @@ -6,6 +6,7 @@ // Copyright © 2020 Twidere. All rights reserved. // +import UIKit import AVKit // MARK: - CustomDebugStringConvertible diff --git a/TwidereX/Extension/UILabel.swift b/TwidereX/Extension/UILabel.swift index 7313c98f..db8cb155 100644 --- a/TwidereX/Extension/UILabel.swift +++ b/TwidereX/Extension/UILabel.swift @@ -7,6 +7,7 @@ // import UIKit +import TwidereCore extension UILabel { diff --git a/TwidereX/Extension/UIViewController.swift b/TwidereX/Extension/UIViewController.swift index 9695e480..dac64a18 100644 --- a/TwidereX/Extension/UIViewController.swift +++ b/TwidereX/Extension/UIViewController.swift @@ -7,45 +7,7 @@ // import UIKit - -extension UIViewController { - - /// Returns the top most view controller from given view controller's stack. - var topMost: UIViewController? { - // presented view controller - if let presentedViewController = presentedViewController { - return presentedViewController.topMost - } - - // UITabBarController - if let tabBarController = self as? UITabBarController, - let selectedViewController = tabBarController.selectedViewController { - return selectedViewController.topMost - } - - // UINavigationController - if let navigationController = self as? UINavigationController, - let visibleViewController = navigationController.visibleViewController { - return visibleViewController.topMost - } - - // UIPageController - if let pageViewController = self as? UIPageViewController, - pageViewController.viewControllers?.count == 1 { - return pageViewController.viewControllers?.first?.topMost ?? self - } - - // child view controller - for subview in self.view?.subviews ?? [] { - if let childViewController = subview.next as? UIViewController { - return childViewController.topMost - } - } - - return self - } - -} +import TwidereCore extension UIViewController { diff --git a/TwidereX/Generated/AppIconAssets.swift b/TwidereX/Generated/AppIconAssets.swift index 62c81ef1..6ef423b9 100644 --- a/TwidereX/Generated/AppIconAssets.swift +++ b/TwidereX/Generated/AppIconAssets.swift @@ -8,6 +8,9 @@ #elseif os(tvOS) || os(watchOS) import UIKit #endif +#if canImport(SwiftUI) + import SwiftUI +#endif // Deprecated typealiases @available(*, deprecated, renamed: "ColorAsset.Color", message: "This typealias will be removed in SwiftGen 7.0") @@ -71,6 +74,13 @@ internal final class ColorAsset { } #endif + #if canImport(SwiftUI) + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) + internal private(set) lazy var swiftUIColor: SwiftUI.Color = { + SwiftUI.Color(asset: self) + }() + #endif + fileprivate init(name: String) { self.name = name } @@ -90,6 +100,16 @@ internal extension ColorAsset.Color { } } +#if canImport(SwiftUI) +@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) +internal extension SwiftUI.Color { + init(asset: ColorAsset) { + let bundle = BundleToken.bundle + self.init(asset.name, bundle: bundle) + } +} +#endif + internal struct ImageAsset { internal fileprivate(set) var name: String @@ -126,6 +146,13 @@ internal struct ImageAsset { return result } #endif + + #if canImport(SwiftUI) + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) + internal var swiftUIImage: SwiftUI.Image { + SwiftUI.Image(asset: self) + } + #endif } internal extension ImageAsset.Image { @@ -144,6 +171,26 @@ internal extension ImageAsset.Image { } } +#if canImport(SwiftUI) +@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) +internal extension SwiftUI.Image { + init(asset: ImageAsset) { + let bundle = BundleToken.bundle + self.init(asset.name, bundle: bundle) + } + + init(asset: ImageAsset, label: Text) { + let bundle = BundleToken.bundle + self.init(asset.name, bundle: bundle, label: label) + } + + init(decorative asset: ImageAsset) { + let bundle = BundleToken.bundle + self.init(decorative: asset.name, bundle: bundle) + } +} +#endif + // swiftlint:disable convenience_type private final class BundleToken { static let bundle: Bundle = { diff --git a/TwidereX/Generated/AutoGenerateTableViewDelegate.generated.swift b/TwidereX/Generated/AutoGenerateTableViewDelegate.generated.swift index 5c657df6..30985739 100644 --- a/TwidereX/Generated/AutoGenerateTableViewDelegate.generated.swift +++ b/TwidereX/Generated/AutoGenerateTableViewDelegate.generated.swift @@ -99,30 +99,7 @@ func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith con aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator) } // sourcery:end -// sourcery:inline:ListTimelineViewController.AutoGenerateTableViewDelegate -// Generated using Sourcery -// DO NOT EDIT -func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - aspectTableView(tableView, didSelectRowAt: indexPath) -} - -func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { - return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point) -} - -func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { - return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration) -} - -func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { - return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration) -} - -func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { - aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator) -} -// sourcery:end // sourcery:inline:SearchTimelineViewController.AutoGenerateTableViewDelegate @@ -151,6 +128,8 @@ func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith con // sourcery:end + + // sourcery:inline:UserTimelineViewController.AutoGenerateTableViewDelegate // Generated using Sourcery diff --git a/TwidereX/Info.plist b/TwidereX/Info.plist index e8195054..56e9fbd7 100644 --- a/TwidereX/Info.plist +++ b/TwidereX/Info.plist @@ -38,9 +38,9 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.4.2 + 2.0.1 CFBundleVersion - 117 + 140 ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS @@ -62,7 +62,7 @@ UIApplicationSceneManifest UIApplicationSupportsMultipleScenes - + UISceneConfigurations UIWindowSceneSessionRoleApplication diff --git a/TwidereX/Protocol/Facade/DataSourceFacade+Banner.swift b/TwidereX/Protocol/Facade/DataSourceFacade+Banner.swift new file mode 100644 index 00000000..2d315b66 --- /dev/null +++ b/TwidereX/Protocol/Facade/DataSourceFacade+Banner.swift @@ -0,0 +1,93 @@ +// +// DataSourceFacade+Banner.swift +// TwidereX +// +// Created by MainasuK on 2022-3-22. +// Copyright © 2022 Twidere. All rights reserved. +// + +import os.log +import UIKit +import SwiftMessages + +extension DataSourceFacade { + + @MainActor + public static func presentSuccessBanner( + title: String + ) { + var config = SwiftMessages.defaultConfig + config.duration = .seconds(seconds: 3) + config.interactiveHide = true + let bannerView = NotificationBannerView() + bannerView.configure(style: .success) + bannerView.titleLabel.text = title + bannerView.messageLabel.isHidden = true + SwiftMessages.show(config: config, view: bannerView) + } + + @MainActor + public static func presentWarningBanner( + title: String, + message: String, + error: Error + ) { + var config = SwiftMessages.defaultConfig + config.duration = .seconds(seconds: 3) + config.interactiveHide = true + let bannerView = NotificationBannerView() + bannerView.configure(style: .warning) + bannerView.titleLabel.text = title + bannerView.messageLabel.text = message + SwiftMessages.show(config: config, view: bannerView) + } + +} + +extension DataSourceFacade { + + @MainActor + public static func presentErrorBanner( + error: LocalizedError + ) { + var config = SwiftMessages.defaultConfig + config.duration = .seconds(seconds: 3) + config.interactiveHide = true + let bannerView = NotificationBannerView() + bannerView.configure(style: .warning) + bannerView.titleLabel.text = error.errorDescription ?? "Unknown Error" + let message = [error.failureReason, error.recoverySuggestion].compactMap { $0 }.joined(separator: "\n") + bannerView.messageLabel.text = message + bannerView.messageLabel.isHidden = message.isEmpty + SwiftMessages.show(config: config, view: bannerView) + } + + @MainActor + public static func presentForbiddenBanner( + error: Error, + dependency: NeedsDependency + ) { + var config = SwiftMessages.defaultConfig + config.duration = .seconds(seconds: 15) + config.interactiveHide = true + let bannerView = NotificationBannerView() + bannerView.configure(style: .warning) + bannerView.titleLabel.text = "Forbidden" // TODO: i18n + bannerView.messageLabel.text = "Application token expired. Please sign in the app again to reactive." + bannerView.messageLabel.numberOfLines = 0 + bannerView.actionButtonTapHandler = { [weak dependency] _ in + guard let dependency = dependency else { return } + let welcomeViewModel = WelcomeViewModel( + context: dependency.context, + configuration: WelcomeViewModel.Configuration(allowDismissModal: true) + ) + dependency.coordinator.present( + scene: .welcome(viewModel: welcomeViewModel), + from: nil, + transition: .modal(animated: true, completion: nil) + ) + } + SwiftMessages.show(config: config, view: bannerView) + } + +} diff --git a/TwidereX/Protocol/Provider/DataSourceFacade+Block.swift b/TwidereX/Protocol/Facade/DataSourceFacade+Block.swift similarity index 100% rename from TwidereX/Protocol/Provider/DataSourceFacade+Block.swift rename to TwidereX/Protocol/Facade/DataSourceFacade+Block.swift diff --git a/TwidereX/Protocol/Provider/DataSourceFacade+Friendship.swift b/TwidereX/Protocol/Facade/DataSourceFacade+Friendship.swift similarity index 94% rename from TwidereX/Protocol/Provider/DataSourceFacade+Friendship.swift rename to TwidereX/Protocol/Facade/DataSourceFacade+Friendship.swift index 754a3004..f2308ee1 100644 --- a/TwidereX/Protocol/Provider/DataSourceFacade+Friendship.swift +++ b/TwidereX/Protocol/Facade/DataSourceFacade+Friendship.swift @@ -10,8 +10,8 @@ import UIKit import CoreData import CoreDataStack import TwidereCore +import TwitterSDK import MastodonSDK -import TwidereUI import SwiftMessages extension DataSourceFacade { @@ -30,6 +30,12 @@ extension DataSourceFacade { authenticationContext: authenticationContext ) await notificationFeedbackGenerator.notificationOccurred(.success) + } catch let error as Twitter.API.Error.ResponseError where error.httpResponseStatus == .forbidden { + await notificationFeedbackGenerator.notificationOccurred(.error) + await presentForbiddenBanner( + error: error, + dependency: provider + ) } catch { await notificationFeedbackGenerator.notificationOccurred(.error) } @@ -56,7 +62,7 @@ extension DataSourceFacade { do { switch (notification, authenticationContext) { - case (_, .twitter): + case (.twitter, .twitter): assertionFailure("Twitter notification has no entry for follow request") return case (.mastodon(let notification), .mastodon(let authenticationContext)): @@ -71,6 +77,9 @@ extension DataSourceFacade { }(), authenticationContext: authenticationContext ) + default: + assertionFailure() + return } // end switch await notificationFeedbackGenerator.notificationOccurred(.success) diff --git a/TwidereX/Protocol/Facade/DataSourceFacade+History.swift b/TwidereX/Protocol/Facade/DataSourceFacade+History.swift new file mode 100644 index 00000000..a19e8c6a --- /dev/null +++ b/TwidereX/Protocol/Facade/DataSourceFacade+History.swift @@ -0,0 +1,96 @@ +// +// DataSourceFacade+History.swift +// TwidereX +// +// Created by MainasuK on 2022-7-29. +// Copyright © 2022 Twidere. All rights reserved. +// + +import Foundation +import CoreData +import CoreDataStack +import TwidereCore + +extension DataSourceFacade { + + static func recordStatusHistory( + denpendency: NeedsDependency & AuthContextProvider, + status: StatusRecord + ) async { + let now = Date() + let authenticationContext = denpendency.authContext.authenticationContext + + let acct = authenticationContext.acct + let managedObjectContext = denpendency.context.backgroundManagedObjectContext + let _history: ManagedObjectRecord? = await managedObjectContext.perform { + guard let status = status.object(in: managedObjectContext) else { return nil } + guard let history = status.histories.first(where: { $0.acct == acct }) else { return nil } + return history.asRecrod + } + + if let history = _history { + try? await managedObjectContext.performChanges { + guard let history = history.object(in: managedObjectContext) else { return } + history.update(timestamp: now) + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): update status history for: \(history.debugDescription)") + } + } else { + try? await managedObjectContext.performChanges { + guard let status = status.object(in: managedObjectContext) else { return } + let history = History.insert( + into: managedObjectContext, + property: .init(acct: acct, timestamp: now, createdAt: now) + ) + switch status { + case .twitter(let object): + history.update(twitterStatus: object) + case .mastodon(let object): + history.update(mastodonStatus: object) + } + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): create status history: \(history.debugDescription)") + + } + } + } // end func + + static func recordUserHistory( + denpendency: NeedsDependency & AuthContextProvider, + user: UserRecord + ) async { + let now = Date() + let authenticationContext = denpendency.authContext.authenticationContext + + let acct = authenticationContext.acct + let managedObjectContext = denpendency.context.backgroundManagedObjectContext + let _history: ManagedObjectRecord? = await managedObjectContext.perform { + guard let user = user.object(in: managedObjectContext) else { return nil } + guard let history = user.histories.first(where: { $0.acct == acct }) else { return nil } + return history.asRecrod + } + + if let history = _history { + try? await managedObjectContext.performChanges { + guard let history = history.object(in: managedObjectContext) else { return } + history.update(timestamp: now) + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): update user history for: \(history.debugDescription)") + } + } else { + try? await managedObjectContext.performChanges { + guard let user = user.object(in: managedObjectContext) else { return } + let history = History.insert( + into: managedObjectContext, + property: .init(acct: acct, timestamp: now, createdAt: now) + ) + switch user { + case .twitter(let object): + history.update(twitterUser: object) + case .mastodon(let object): + history.update(mastodonUser: object) + } + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): create user history: \(history.debugDescription)") + + } + } + } + +} diff --git a/TwidereX/Protocol/Provider/DataSourceFacade+LIst.swift b/TwidereX/Protocol/Facade/DataSourceFacade+LIst.swift similarity index 96% rename from TwidereX/Protocol/Provider/DataSourceFacade+LIst.swift rename to TwidereX/Protocol/Facade/DataSourceFacade+LIst.swift index 3720c62f..ffd268d6 100644 --- a/TwidereX/Protocol/Provider/DataSourceFacade+LIst.swift +++ b/TwidereX/Protocol/Facade/DataSourceFacade+LIst.swift @@ -17,11 +17,12 @@ import SwiftMessages extension DataSourceFacade { static func coordinateToListMemberScene( - dependency: NeedsDependency & UIViewController, + dependency: NeedsDependency & AuthContextProvider & UIViewController, list: ListRecord ) async { let listUserViewModel = ListUserViewModel( context: dependency.context, + authContext: dependency.authContext, kind: .members(list: list) ) await dependency.coordinator.present( @@ -32,11 +33,12 @@ extension DataSourceFacade { } static func coordinateToListSubscriberScene( - dependency: NeedsDependency & UIViewController, + dependency: NeedsDependency & AuthContextProvider & UIViewController, list: ListRecord ) async { let listUserViewModel = ListUserViewModel( context: dependency.context, + authContext: dependency.authContext, kind: .subscribers(list: list) ) await dependency.coordinator.present( @@ -51,7 +53,7 @@ extension DataSourceFacade { extension DataSourceFacade { static func createMenuForList( - dependency: NeedsDependency & UIViewController, + dependency: NeedsDependency & AuthContextProvider & UIViewController, list: ListRecord, authenticationContext: AuthenticationContext ) async throws -> UIMenu { @@ -218,7 +220,7 @@ extension DataSourceFacade { ) { _ in Task { @MainActor [weak dependency] in guard let dependency = dependency else { return } - let profileViewModel = LocalProfileViewModel(context: dependency.context, userRecord: owner) + let profileViewModel = LocalProfileViewModel(context: dependency.context, authContext: dependency.authContext, userRecord: owner) dependency.coordinator.present( scene: .profile(viewModel: profileViewModel), from: dependency, @@ -375,12 +377,13 @@ extension DataSourceFacade { @MainActor static func responseToListEditAction( - dependency: NeedsDependency & UIViewController, + dependency: NeedsDependency & AuthContextProvider & UIViewController, list: ListRecord, authenticationContext: AuthenticationContext ) async throws { let editListViewModel = EditListViewModel( context: dependency.context, + authContext: dependency.authContext, platform: { switch list { case .twitter: return .twitter diff --git a/TwidereX/Protocol/Provider/DataSourceFacade+Like.swift b/TwidereX/Protocol/Facade/DataSourceFacade+Like.swift similarity index 75% rename from TwidereX/Protocol/Provider/DataSourceFacade+Like.swift rename to TwidereX/Protocol/Facade/DataSourceFacade+Like.swift index a9102995..5557ba53 100644 --- a/TwidereX/Protocol/Provider/DataSourceFacade+Like.swift +++ b/TwidereX/Protocol/Facade/DataSourceFacade+Like.swift @@ -9,6 +9,7 @@ import UIKit import CoreData import CoreDataStack +import TwitterSDK extension DataSourceFacade { static func responseToStatusLikeAction( @@ -26,6 +27,12 @@ extension DataSourceFacade { authenticationContext: authenticationContext ) await notificationFeedbackGenerator.notificationOccurred(.success) + } catch let error as Twitter.API.Error.ResponseError where error.httpResponseStatus == .forbidden { + await notificationFeedbackGenerator.notificationOccurred(.error) + await presentForbiddenBanner( + error: error, + dependency: provider + ) } catch { await notificationFeedbackGenerator.notificationOccurred(.error) } diff --git a/TwidereX/Protocol/Facade/DataSourceFacade+Media.swift b/TwidereX/Protocol/Facade/DataSourceFacade+Media.swift new file mode 100644 index 00000000..fa3ada5d --- /dev/null +++ b/TwidereX/Protocol/Facade/DataSourceFacade+Media.swift @@ -0,0 +1,257 @@ +// +// DataSourceFacade+Media.swift +// TwidereX +// +// Created by MainasuK on 2021-12-7. +// Copyright © 2021 Twidere. All rights reserved. +// + +import UIKit +import AVKit +import TwidereCore + +extension DataSourceFacade { + + @MainActor + static func coordinateToMediaPreviewScene( + provider: DataSourceProvider & AuthContextProvider & MediaPreviewableViewController, + status: StatusRecord, + statusViewModel: StatusView.ViewModel, + mediaViewModel: MediaView.ViewModel, + previewActionContext: ContextMenuInteractionPreviewActionContext? = nil, + animated: Bool = true + ) { + guard let index = statusViewModel.mediaViewModels.firstIndex(of: mediaViewModel) else { + assertionFailure("invalid callback") + return + } + let thumbnails = statusViewModel.mediaViewModels.map { $0.thumbnail } + + // note: + // previewActionContext will automatically dismiss with fade animation style + previewActionContext?.animator.preferredCommitStyle = .dismiss + let _initialFrame: CGRect? = { + guard let platterClippingView = previewActionContext?.platterClippingView() else { return nil } + return platterClippingView.convert(platterClippingView.frame, to: nil) + }() + + coordinateToMediaPreviewScene( + provider: provider, + status: status, + mediaPreviewItem: .statusMedia(.init( + status: status, + mediaViewModels: statusViewModel.mediaViewModels, + initialIndex: index, + preloadThumbnails: thumbnails + )), + mediaPreviewTransitionItem: { + let source = MediaPreviewTransitionItem.Source.mediaView(mediaViewModel, viewModels: statusViewModel.mediaViewModels) + let item = MediaPreviewTransitionItem( + source: source, + previewableViewController: provider + ) + + // use the contextMenu previewView frame if possible + // so that the transition will continue from the previewView position + item.initialFrame = _initialFrame ?? mediaViewModel.frameInWindow + + let thumbnail = mediaViewModel.thumbnail + item.image = thumbnail + + item.aspectRatio = { + if let thumbnail = thumbnail { + return thumbnail.size + } + return mediaViewModel.aspectRatio + }() + + item.sourceImageViewCornerRadius = MediaGridContainerView.cornerRadius + + return item + }(), + animated: animated + ) // end coordinateToMediaPreviewScene + } + + @MainActor + static func coordinateToMediaPreviewScene( + provider: DataSourceProvider & AuthContextProvider & MediaPreviewableViewController, + status: StatusRecord, + mediaPreviewItem: MediaPreviewViewModel.Item, + mediaPreviewTransitionItem: MediaPreviewTransitionItem, + animated: Bool + ) { + let mediaPreviewViewModel = MediaPreviewViewModel( + context: provider.context, + authContext: provider.authContext, + item: mediaPreviewItem, + transitionItem: mediaPreviewTransitionItem + ) + provider.coordinator.present( + scene: .mediaPreview(viewModel: mediaPreviewViewModel), + from: provider, + transition: .custom(animated: animated, transitioningDelegate: provider.mediaPreviewTransitionController) + ) + } + +} + +extension DataSourceFacade { + + @MainActor + static func responseToMediaViewAction( + provider: DataSourceProvider & AuthContextProvider & MediaPreviewableViewController, + statusViewModel: StatusView.ViewModel, + mediaViewModel: MediaView.ViewModel, + action: MediaView.ViewModel.Action + ) { + switch action { + case .preview: + assert(Thread.isMainThread) + guard let status = statusViewModel.status?.asRecord else { return } + DataSourceFacade.coordinateToMediaPreviewScene( + provider: provider, + status: status, + statusViewModel: statusViewModel, + mediaViewModel: mediaViewModel + ) + case .previewWithContext(let previewActionContext): + assert(Thread.isMainThread) + guard let status = statusViewModel.status?.asRecord else { return } + DataSourceFacade.coordinateToMediaPreviewScene( + provider: provider, + status: status, + statusViewModel: statusViewModel, + mediaViewModel: mediaViewModel, + previewActionContext: previewActionContext + ) + case .save: + Task { + await responseToMediaViewSaveAction( + provider: provider, + mediaViewModel: mediaViewModel + ) + } // end Task + case .copy: + Task { + await responseToMediaViewCopyAction( + provider: provider, + mediaViewModel: mediaViewModel + ) + } // end Task + case .shareLink: + Task { + await responseToMediaViewShareLinkAction( + provider: provider, + mediaViewModel: mediaViewModel + ) + } // end Task + case .shareMedia: + Task { + await responseToMediaViewShareMediaAction( + provider: provider, + mediaViewModel: mediaViewModel + ) + } // end Task + } // end switch + } + +} + +extension DataSourceFacade { + + @MainActor + static func responseToMediaViewSaveAction( + provider: DataSourceProvider & AuthContextProvider & MediaPreviewableViewController, + mediaViewModel: MediaView.ViewModel + ) async { + guard let assetURL = mediaViewModel.downloadURL else { return } + + let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) + let notificationFeedbackGenerator = UINotificationFeedbackGenerator() + + do { + impactFeedbackGenerator.impactOccurred() + try await provider.context.photoLibraryService.save( + source: .remote(url: assetURL), + resourceType: mediaViewModel.mediaKind.resourceType + ) + provider.context.photoLibraryService.presentSuccessNotification(title: L10n.Common.Alerts.PhotoSaved.title) + notificationFeedbackGenerator.notificationOccurred(.success) + } catch { + provider.context.photoLibraryService.presentFailureNotification( + error: error, + title: L10n.Common.Alerts.PhotoSaveFail.title, + message: L10n.Common.Alerts.PhotoSaveFail.message + ) + notificationFeedbackGenerator.notificationOccurred(.error) + } + } + + @MainActor + static func responseToMediaViewCopyAction( + provider: DataSourceProvider & AuthContextProvider & MediaPreviewableViewController, + mediaViewModel: MediaView.ViewModel + ) async { + guard let assetURL = mediaViewModel.downloadURL else { return } + + let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) + let notificationFeedbackGenerator = UINotificationFeedbackGenerator() + + do { + impactFeedbackGenerator.impactOccurred() + try await provider.context.photoLibraryService.copy( + source: .remote(url: assetURL), + resourceType: mediaViewModel.mediaKind.resourceType + ) + provider.context.photoLibraryService.presentSuccessNotification(title: L10n.Common.Alerts.PhotoCopied.title) + notificationFeedbackGenerator.notificationOccurred(.success) + } catch { + provider.context.photoLibraryService.presentFailureNotification( + error: error, + title: L10n.Common.Alerts.PhotoCopied.title, + message: L10n.Common.Alerts.PhotoCopyFail.message + ) + notificationFeedbackGenerator.notificationOccurred(.error) + } + } + + @MainActor + static func responseToMediaViewShareLinkAction( + provider: DataSourceProvider & AuthContextProvider & MediaPreviewableViewController, + mediaViewModel: MediaView.ViewModel + ) async { + guard let assetURL = mediaViewModel.downloadURL else { return } + + let applicationActivities: [UIActivity] = [ + SafariActivity(sceneCoordinator: provider.coordinator) + ] + let activityViewController = UIActivityViewController( + activityItems: [assetURL], + applicationActivities: applicationActivities + ) + activityViewController.popoverPresentationController?.sourceRect = mediaViewModel.frameInWindow + provider.present(activityViewController, animated: true, completion: nil) + } + + @MainActor + static func responseToMediaViewShareMediaAction( + provider: DataSourceProvider & AuthContextProvider & MediaPreviewableViewController, + mediaViewModel: MediaView.ViewModel + ) async { + guard let assetURL = mediaViewModel.downloadURL else { return } + guard let url = try? await provider.context.photoLibraryService.file(from: .remote(url: assetURL)) else { + return + } + + let applicationActivities: [UIActivity] = [ + SafariActivity(sceneCoordinator: provider.coordinator) + ] + let activityViewController = UIActivityViewController( + activityItems: [url], + applicationActivities: applicationActivities + ) + activityViewController.popoverPresentationController?.sourceRect = mediaViewModel.frameInWindow + provider.present(activityViewController, animated: true, completion: nil) + } +} diff --git a/TwidereX/Protocol/Provider/DataSourceFacade+Meta.swift b/TwidereX/Protocol/Facade/DataSourceFacade+Meta.swift similarity index 67% rename from TwidereX/Protocol/Provider/DataSourceFacade+Meta.swift rename to TwidereX/Protocol/Facade/DataSourceFacade+Meta.swift index 8f788617..0543c575 100644 --- a/TwidereX/Protocol/Provider/DataSourceFacade+Meta.swift +++ b/TwidereX/Protocol/Facade/DataSourceFacade+Meta.swift @@ -13,39 +13,19 @@ import MetaTextArea import Meta extension DataSourceFacade { - static func responseToMetaTextAreaView( - provider: DataSourceProvider, - target: StatusTarget, - status: StatusRecord, - metaTextAreaView: MetaTextAreaView, - didSelectMeta meta: Meta - ) async { - let _redirectRecord = await DataSourceFacade.status( - managedObjectContext: provider.context.managedObjectContext, - status: status, - target: target - ) - guard let redirectRecord = _redirectRecord else { return } - - await responseToMetaTextAreaView( - provider: provider, - status: redirectRecord, - metaTextAreaView: metaTextAreaView, - didSelectMeta: meta - ) - } - - static func responseToMetaTextAreaView( - provider: DataSourceProvider, + static func responseToMetaText( + provider: DataSourceProvider & AuthContextProvider, status: StatusRecord, - metaTextAreaView: MetaTextAreaView, didSelectMeta meta: Meta ) async { switch meta { case .url(_, _, let url, _): await provider.coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil)) case .hashtag(_, let hashtag, _): - let hashtagViewModel = HashtagTimelineViewModel(context: provider.context, hashtag: hashtag) + let hashtagViewModel = HashtagTimelineViewModel(context: provider.context, authContext: provider.authContext, hashtag: hashtag) + await provider.coordinator.present(scene: .hashtagTimeline(viewModel: hashtagViewModel), from: provider, transition: .show) + case .cashtag(let text, _, _): + let hashtagViewModel = HashtagTimelineViewModel(context: provider.context, authContext: provider.authContext, hashtag: text) await provider.coordinator.present(scene: .hashtagTimeline(viewModel: hashtagViewModel), from: provider, transition: .show) case .mention(_, let mention, let userInfo): await DataSourceFacade.coordinateToProfileScene( @@ -62,7 +42,7 @@ extension DataSourceFacade { extension DataSourceFacade { static func responseToMetaTextAreaView( - provider: DataSourceProvider, + provider: DataSourceProvider & AuthContextProvider, user: UserRecord, didSelectMeta meta: Meta ) async { @@ -70,7 +50,10 @@ extension DataSourceFacade { case .url(_, _, let url, _): await provider.coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil)) case .hashtag(_, let hashtag, _): - let hashtagViewModel = HashtagTimelineViewModel(context: provider.context, hashtag: hashtag) + let hashtagViewModel = HashtagTimelineViewModel(context: provider.context, authContext: provider.authContext, hashtag: hashtag) + await provider.coordinator.present(scene: .hashtagTimeline(viewModel: hashtagViewModel), from: provider, transition: .show) + case .cashtag(let text, _, _): + let hashtagViewModel = HashtagTimelineViewModel(context: provider.context, authContext: provider.authContext, hashtag: text) await provider.coordinator.present(scene: .hashtagTimeline(viewModel: hashtagViewModel), from: provider, transition: .show) case .mention(_, let mention, let userInfo): await coordinateToProfileScene( diff --git a/TwidereX/Protocol/Provider/DataSourceFacade+Model.swift b/TwidereX/Protocol/Facade/DataSourceFacade+Model.swift similarity index 100% rename from TwidereX/Protocol/Provider/DataSourceFacade+Model.swift rename to TwidereX/Protocol/Facade/DataSourceFacade+Model.swift diff --git a/TwidereX/Protocol/Provider/DataSourceFacade+Mute.swift b/TwidereX/Protocol/Facade/DataSourceFacade+Mute.swift similarity index 100% rename from TwidereX/Protocol/Provider/DataSourceFacade+Mute.swift rename to TwidereX/Protocol/Facade/DataSourceFacade+Mute.swift diff --git a/TwidereX/Protocol/Provider/DataSourceFacade+Poll.swift b/TwidereX/Protocol/Facade/DataSourceFacade+Poll.swift similarity index 68% rename from TwidereX/Protocol/Provider/DataSourceFacade+Poll.swift rename to TwidereX/Protocol/Facade/DataSourceFacade+Poll.swift index 2b2dfde2..d1dd5d13 100644 --- a/TwidereX/Protocol/Provider/DataSourceFacade+Poll.swift +++ b/TwidereX/Protocol/Facade/DataSourceFacade+Poll.swift @@ -7,15 +7,45 @@ // import UIKit -import TwidereUI +import TwidereCore import CoreDataStack +extension DataSourceFacade { + public static func responseToStatusPollUpdate( + provider: DataSourceProvider & AuthContextProvider, + status: StatusRecord + ) async throws { + let managedObjectContext = provider.context.managedObjectContext + switch (status, provider.authContext.authenticationContext) { + case (.twitter(let status), .twitter(let authenticationContext)): + let _statusID = await managedObjectContext.perform { + return status.object(in: managedObjectContext)?.id + } + guard let statusID = _statusID else { + assertionFailure() + return + } + _ = try await provider.context.apiService.twitterStatus( + statusIDs: [statusID], + authenticationContext: authenticationContext + ) + case (.mastodon(let status), .mastodon(let authenticationContext)): + _ = try await provider.context.apiService.viewMastodonStatusPoll( + status: status, + authenticationContext: authenticationContext + ) + default: + assertionFailure() + } + } +} + extension DataSourceFacade { public static func responseToStatusPollOption( provider: DataSourceProvider, target: StatusTarget, status: StatusRecord, - didSelectRowAt indexPath: IndexPath + didSelectRowAt index: Int ) async { let _redirectRecord = await DataSourceFacade.status( managedObjectContext: provider.context.managedObjectContext, @@ -27,16 +57,16 @@ extension DataSourceFacade { await responseToStatusPollOption( provider: provider, status: redirectRecord, - didSelectRowAt: indexPath + didSelectRowAt: index ) } static func responseToStatusPollOption( provider: DataSourceProvider, status: StatusRecord, - didSelectRowAt indexPath: IndexPath + didSelectRowAt index: Int ) async { - // should use same context on UI to make transient property trigger update + // use same context on UI to make transient property trigger update let managedObjectContext = provider.context.managedObjectContext do { @@ -55,8 +85,7 @@ extension DataSourceFacade { guard !poll.isVoting else { return } - - guard let option = poll.options.first(where: { $0.index == indexPath.row }) else { + guard let option = poll.options.first(where: { $0.index == index }) else { assertionFailure() return } @@ -81,53 +110,26 @@ extension DataSourceFacade { extension DataSourceFacade { - public static func responseToStatusPollOption( - provider: DataSourceProvider, - target: StatusTarget, - status: StatusRecord, - voteButtonDidPressed button: UIButton - ) async { - let _redirectRecord = await DataSourceFacade.status( - managedObjectContext: provider.context.managedObjectContext, - status: status, - target: target - ) - guard let redirectRecord = _redirectRecord else { return } - - await responseToStatusPollOption( - provider: provider, - status: redirectRecord, - voteButtonDidPressed: button - ) - } - - static func responseToStatusPollOption( - provider: DataSourceProvider, - status: StatusRecord, - voteButtonDidPressed button: UIButton - ) async { - do { - switch status { - case .twitter: - assertionFailure() - case .mastodon(let record): - try await responseToStatusPollOption( - provider: provider, - status: record, - voteButtonDidPressed: button - ) - } - } catch { - // TODO: handle error + static func responseToStatusPollVote( + provider: DataSourceProvider & AuthContextProvider, + status: StatusRecord + ) async throws { + switch status { + case .twitter: + assertionFailure() + case .mastodon(let record): + try await responseToStatusPollVote( + provider: provider, + status: record + ) } } - private static func responseToStatusPollOption( - provider: DataSourceProvider, - status: ManagedObjectRecord, - voteButtonDidPressed button: UIButton + private static func responseToStatusPollVote( + provider: DataSourceProvider & AuthContextProvider, + status: ManagedObjectRecord ) async throws { - guard case let .mastodon(authenticationContext) = provider.context.authenticationService.activeAuthenticationContext else { return } + guard case let .mastodon(authenticationContext) = provider.authContext.authenticationContext else { return } // should use same context on UI to make transient property trigger update let managedObjectContext = provider.context.managedObjectContext @@ -153,7 +155,7 @@ extension DataSourceFacade { return choices.map { Int($0) } } - await Task.sleep(1_000_000_000) // 1s + try? await Task.sleep(nanoseconds: 1 * .second) // 1s let response = try await provider.context.apiService.voteMastodonStatusPoll( status: status, choices: choices, @@ -162,7 +164,6 @@ extension DataSourceFacade { provider.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): did vote poll: \(response.value.id) with choices: \(choices.debugDescription)") } catch { - assertionFailure(error.localizedDescription) _error = error } @@ -180,6 +181,10 @@ extension DataSourceFacade { _error = error } + if let error = _error as? LocalizedError { + await DataSourceFacade.presentErrorBanner(error: error) + } + if let error = _error { throw error } diff --git a/TwidereX/Protocol/Provider/DataSourceFacade+Profile.swift b/TwidereX/Protocol/Facade/DataSourceFacade+Profile.swift similarity index 88% rename from TwidereX/Protocol/Provider/DataSourceFacade+Profile.swift rename to TwidereX/Protocol/Facade/DataSourceFacade+Profile.swift index e633e8b6..f1ceef63 100644 --- a/TwidereX/Protocol/Provider/DataSourceFacade+Profile.swift +++ b/TwidereX/Protocol/Facade/DataSourceFacade+Profile.swift @@ -7,13 +7,13 @@ // import Foundation -import TwidereCore import CoreDataStack +import TwidereCore extension DataSourceFacade { static func coordinateToProfileScene( - provider: DataSourceProvider, + provider: DataSourceProvider & AuthContextProvider, target: StatusTarget, status: StatusRecord ) async { @@ -34,11 +34,12 @@ extension DataSourceFacade { @MainActor static func coordinateToProfileScene( - provider: DataSourceProvider, + provider: DataSourceProvider & AuthContextProvider, user: UserRecord ) async { let profileViewModel = LocalProfileViewModel( context: provider.context, + authContext: provider.authContext, userRecord: user ) provider.coordinator.present( @@ -46,6 +47,13 @@ extension DataSourceFacade { from: provider, transition: .show ) + + Task { + await recordUserHistory( + denpendency: provider, + user: user + ) + } // end Task } } @@ -54,7 +62,7 @@ extension DataSourceFacade { extension DataSourceFacade { static func coordinateToProfileScene( - provider: DataSourceProvider, + provider: DataSourceProvider & AuthContextProvider, status: StatusRecord, mention: String, // username, userInfo: [AnyHashable: Any]? @@ -64,7 +72,7 @@ extension DataSourceFacade { switch object { case .twitter(let status): let status = status.repost ?? status - let mentions = status.entities?.mentions ?? [] + let mentions = status.entitiesTransient?.mentions ?? [] let _userID: TwitterUser.ID? = mentions.first(where: { $0.username == mention })?.id if let userID = _userID { @@ -84,7 +92,7 @@ extension DataSourceFacade { case .mastodon(let status): let status = status.repost ?? status - guard let mention = status.mentions.first(where: { mention == $0.username }) else { + guard let mention = status.mentionsTransient.first(where: { mention == $0.username }) else { return nil } @@ -121,7 +129,7 @@ extension DataSourceFacade { } static func coordinateToProfileScene( - provider: DataSourceProvider, + provider: DataSourceProvider & AuthContextProvider, user: UserRecord, mention: String, // username, userInfo: [AnyHashable: Any]? @@ -130,7 +138,7 @@ extension DataSourceFacade { guard let object = user.object(in: provider.context.managedObjectContext) else { return nil } switch object { case .twitter(let user): - let mentions = user.bioEntities?.mentions ?? [] + let mentions = user.bioEntitiesTransient?.mentions ?? [] let _userID = mentions.first(where: { $0.username == mention })?.id if let userID = _userID { @@ -171,11 +179,12 @@ extension DataSourceFacade { @MainActor static func coordinateToProfileScene( - provider: DataSourceProvider, + provider: DataSourceProvider & AuthContextProvider, profileContext: RemoteProfileViewModel.ProfileContext ) async { let profileViewModel = RemoteProfileViewModel( context: provider.context, + authContext: provider.authContext, profileContext: profileContext ) provider.coordinator.present( diff --git a/TwidereX/Protocol/Provider/DataSourceFacade+Report.swift b/TwidereX/Protocol/Facade/DataSourceFacade+Report.swift similarity index 96% rename from TwidereX/Protocol/Provider/DataSourceFacade+Report.swift rename to TwidereX/Protocol/Facade/DataSourceFacade+Report.swift index d9b53b91..83bf523a 100644 --- a/TwidereX/Protocol/Provider/DataSourceFacade+Report.swift +++ b/TwidereX/Protocol/Facade/DataSourceFacade+Report.swift @@ -10,7 +10,6 @@ import UIKit import TwidereAsset import TwidereLocalization import SwiftMessages -import TwidereUI extension DataSourceFacade { @MainActor @@ -85,8 +84,7 @@ extension DataSourceFacade { } } - // TODO: i18n - let alertControllerTitle = performBlock ? "Do you want to report and block \(reportAlertContext.name)" : "Do you want to report \(reportAlertContext.name)" + let alertControllerTitle = performBlock ? L10n.Common.Controls.Friendship.doYouWantToReportAndBlockUser(reportAlertContext.name) : L10n.Common.Controls.Friendship.doYouWantToReportUser(reportAlertContext.name) let alertController = UIAlertController( title: alertControllerTitle, diff --git a/TwidereX/Protocol/Provider/DataSourceFacade+Repost.swift b/TwidereX/Protocol/Facade/DataSourceFacade+Repost.swift similarity index 100% rename from TwidereX/Protocol/Provider/DataSourceFacade+Repost.swift rename to TwidereX/Protocol/Facade/DataSourceFacade+Repost.swift diff --git a/TwidereX/Protocol/Provider/DataSourceFacade+SavedSearch.swift b/TwidereX/Protocol/Facade/DataSourceFacade+SavedSearch.swift similarity index 95% rename from TwidereX/Protocol/Provider/DataSourceFacade+SavedSearch.swift rename to TwidereX/Protocol/Facade/DataSourceFacade+SavedSearch.swift index 8f8f7177..197937d1 100644 --- a/TwidereX/Protocol/Provider/DataSourceFacade+SavedSearch.swift +++ b/TwidereX/Protocol/Facade/DataSourceFacade+SavedSearch.swift @@ -14,13 +14,14 @@ extension DataSourceFacade { @MainActor static func coordinateToSearchResult( - dependency: NeedsDependency & UIViewController, + dependency: NeedsDependency & AuthContextProvider & UIViewController, savedSearch: SavedSearchRecord ) { guard let savedResult = savedSearch.object(in: dependency.context.managedObjectContext) else { return } let searchResultViewModel = SearchResultViewModel( context: dependency.context, + authContext: dependency.authContext, coordinator: dependency.coordinator ) searchResultViewModel.searchText = savedResult.query @@ -33,11 +34,12 @@ extension DataSourceFacade { @MainActor static func coordinateToSearchResult( - dependency: NeedsDependency & UIViewController, + dependency: NeedsDependency & AuthContextProvider & UIViewController, trend object: TrendObject ) { let searchResultViewModel = SearchResultViewModel( context: dependency.context, + authContext: dependency.authContext, coordinator: dependency.coordinator ) searchResultViewModel.searchText = object.query diff --git a/TwidereX/Protocol/Provider/DataSourceFacade+Share.swift b/TwidereX/Protocol/Facade/DataSourceFacade+Share.swift similarity index 83% rename from TwidereX/Protocol/Provider/DataSourceFacade+Share.swift rename to TwidereX/Protocol/Facade/DataSourceFacade+Share.swift index cbb8a42a..c1d5adb3 100644 --- a/TwidereX/Protocol/Provider/DataSourceFacade+Share.swift +++ b/TwidereX/Protocol/Facade/DataSourceFacade+Share.swift @@ -14,7 +14,7 @@ extension DataSourceFacade { public static func responseToStatusShareAction( provider: DataSourceProvider, status: StatusRecord, - button: UIButton + sourceView: UIView ) async { let activityViewController = await createActivityViewController( provider: provider, @@ -23,7 +23,7 @@ extension DataSourceFacade { provider.coordinator.present( scene: .activityViewController( activityViewController: activityViewController, - sourceView: button + sourceView: sourceView ), from: provider, transition: .activityViewControllerPresent(animated: true, completion: nil) @@ -40,13 +40,8 @@ extension DataSourceFacade { ) async -> UIActivityViewController { var activityItems: [Any] = await provider.context.managedObjectContext.perform { guard let object = status.object(in: provider.context.managedObjectContext) else { return [] } - switch object { - case .twitter(let status): - return [status.statusURL] - case .mastodon(let status): - let url = status.url ?? status.uri - return [URL(string: url)].compactMap { $0 } as [Any] - } + guard let url = object.statusURL else { return [] } + return [url] as [Any] } var applicationActivities: [UIActivity] = [ SafariActivity(sceneCoordinator: provider.coordinator), // open URL diff --git a/TwidereX/Protocol/Provider/DataSourceFacade+Status.swift b/TwidereX/Protocol/Facade/DataSourceFacade+Status.swift similarity index 55% rename from TwidereX/Protocol/Provider/DataSourceFacade+Status.swift rename to TwidereX/Protocol/Facade/DataSourceFacade+Status.swift index 6510cf22..4e789043 100644 --- a/TwidereX/Protocol/Provider/DataSourceFacade+Status.swift +++ b/TwidereX/Protocol/Facade/DataSourceFacade+Status.swift @@ -8,39 +8,41 @@ import os.log import UIKit -import AppShared -import TwidereCore -import TwidereUI import SwiftMessages extension DataSourceFacade { @MainActor static func responseToStatusToolbar( - provider: DataSourceProvider, + provider: DataSourceProvider & AuthContextProvider, + viewModel: StatusView.ViewModel, + statusToolbarViewModel: StatusToolbarView.ViewModel, status: StatusRecord, - action: StatusToolbar.Action, - sender: UIButton, - authenticationContext: AuthenticationContext + action: StatusToolbarView.Action ) async { + defer { + Task { + await recordStatusHistory( + denpendency: provider, + status: status + ) + } // end Task + } + switch action { case .reply: guard let status = status.object(in: provider.context.managedObjectContext) else { assertionFailure() return } + + let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) + impactFeedbackGenerator.impactOccurred() + let composeViewModel = ComposeViewModel(context: provider.context) let composeContentViewModel = ComposeContentViewModel( - kind: .reply(status: status), - configurationContext: ComposeContentViewModel.ConfigurationContext( - apiService: provider.context.apiService, - authenticationService: provider.context.authenticationService, - mastodonEmojiService: provider.context.mastodonEmojiService, - statusViewConfigureContext: .init( - dateTimeProvider: DateTimeSwiftProvider(), - twitterTextProvider: OfficialTwitterTextProvider(), - authenticationContext: provider.context.authenticationService.$activeAuthenticationContext - ) - ) + context: provider.context, + authContext: provider.authContext, + kind: .reply(status: status) ) provider.coordinator.present( scene: .compose( @@ -55,7 +57,7 @@ extension DataSourceFacade { try await DataSourceFacade.responseToStatusRepostAction( provider: provider, status: status, - authenticationContext: authenticationContext + authenticationContext: provider.authContext.authenticationContext ) // update store review count trigger @@ -63,12 +65,36 @@ extension DataSourceFacade { } catch { provider.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): update repost failure: \(error.localizedDescription)") } + + case .quote: + guard let status = status.object(in: provider.context.managedObjectContext) else { + assertionFailure() + return + } + + let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) + impactFeedbackGenerator.impactOccurred() + + let composeViewModel = ComposeViewModel(context: provider.context) + let composeContentViewModel = ComposeContentViewModel( + context: provider.context, + authContext: provider.authContext, + kind: .quote(status: status) + ) + provider.coordinator.present( + scene: .compose( + viewModel: composeViewModel, + contentViewModel: composeContentViewModel + ), + from: provider, + transition: .modal(animated: true, completion: nil) + ) case .like: do { try await DataSourceFacade.responseToStatusLikeAction( provider: provider, status: status, - authenticationContext: authenticationContext + authenticationContext: provider.authContext.authenticationContext ) // update store review count trigger @@ -76,12 +102,82 @@ extension DataSourceFacade { } catch { provider.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): update like failure: \(error.localizedDescription)") } - case .menu: - // media menu button trigger this + case .copyText: + let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) + impactFeedbackGenerator.impactOccurred() + + let plaintext = viewModel.content.string + UIPasteboard.general.string = plaintext + case .copyLink: + let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) + impactFeedbackGenerator.impactOccurred() + + let _link: String? = await provider.context.managedObjectContext.perform { + guard let object = status.object(in: provider.context.managedObjectContext) else { return nil } + guard let url = object.statusURL else { return nil } + return url.absoluteString + } + guard let link = _link else { return } + UIPasteboard.general.string = link + case .shareLink: + let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) + impactFeedbackGenerator.impactOccurred() + await DataSourceFacade.responseToStatusShareAction( provider: provider, status: status, - button: sender + sourceView: statusToolbarViewModel.menuButtonBackgroundView + ) + case .saveMedia: + let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) + let notificationFeedbackGenerator = UINotificationFeedbackGenerator() + + let mediaViewModels = viewModel.mediaViewModels + do { + impactFeedbackGenerator.impactOccurred() + for mediaViewModel in mediaViewModels { + guard let url = mediaViewModel.downloadURL else { + assertionFailure() + continue + } + try await provider.context.photoLibraryService.save( + source: .remote(url: url), + resourceType: { + switch mediaViewModel.mediaKind { + case .video: return .video + case .animatedGIF: return .video + case .photo: return .photo + } + }() + ) + } + provider.context.photoLibraryService.presentSuccessNotification(title: L10n.Common.Alerts.PhotoSaved.title) + notificationFeedbackGenerator.notificationOccurred(.success) + } catch { + provider.context.photoLibraryService.presentFailureNotification( + error: error, + title: L10n.Common.Alerts.PhotoSaveFail.title, + message: L10n.Common.Alerts.PhotoSaveFail.message + ) + notificationFeedbackGenerator.notificationOccurred(.error) + } + case .translate: + let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) + impactFeedbackGenerator.impactOccurred() + do { + try await DataSourceFacade.responseToStatusTranslate( + provider: provider, + status: status + ) + } catch { + assertionFailure(error.localizedDescription) + } + case .delete: + await DataSourceFacade.responseToRemoveStatusAction( + provider: provider, + target: .status, + status: status, + authenticationContext: provider.authContext.authenticationContext ) } // end switch action } @@ -89,8 +185,8 @@ extension DataSourceFacade { extension DataSourceFacade { - static func responseToExpandContentAction( - provider: DataSourceProvider, + static func responseToToggleContentSensitiveAction( + provider: DataSourceProvider & AuthContextProvider, target: StatusTarget, status: StatusRecord ) async throws { @@ -101,14 +197,21 @@ extension DataSourceFacade { ) guard let redirectRecord = _redirectRecord else { return } - try await responseToExpandContentAction( + try await responseToToggleContentSensitiveAction( provider: provider, status: redirectRecord ) + + Task { + await recordStatusHistory( + denpendency: provider, + status: status + ) + } // end Task } @MainActor - static func responseToExpandContentAction( + static func responseToToggleContentSensitiveAction( provider: DataSourceProvider, status: StatusRecord ) async throws { @@ -129,7 +232,7 @@ extension DataSourceFacade { extension DataSourceFacade { static func responseToToggleMediaSensitiveAction( - provider: DataSourceProvider, + provider: DataSourceProvider & AuthContextProvider, target: StatusTarget, status: StatusRecord ) async throws { @@ -144,6 +247,13 @@ extension DataSourceFacade { provider: provider, status: redirectRecord ) + + Task { + await recordStatusHistory( + denpendency: provider, + status: status + ) + } // end Task } @MainActor @@ -151,11 +261,13 @@ extension DataSourceFacade { provider: DataSourceProvider, status: StatusRecord ) async throws { - try await provider.context.backgroundManagedObjectContext.performChanges { - guard let object = status.object(in: provider.context.managedObjectContext) else { return } + // use same context on UI to make transient property trigger update + let managedObjectContext = provider.context.managedObjectContext + 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) } @@ -171,7 +283,7 @@ extension DataSourceFacade { target: StatusTarget, status: StatusRecord, authenticationContext: AuthenticationContext - ) async throws { + ) async { let _redirectRecord = await DataSourceFacade.status( managedObjectContext: provider.context.managedObjectContext, status: status, @@ -179,7 +291,7 @@ extension DataSourceFacade { ) guard let redirectRecord = _redirectRecord else { return } - try await responseToRemoveStatusAction( + await responseToRemoveStatusAction( provider: provider, status: redirectRecord, authenticationContext: authenticationContext @@ -191,7 +303,7 @@ extension DataSourceFacade { provider: DataSourceProvider, status: StatusRecord, authenticationContext: AuthenticationContext - ) async throws { + ) async { let title: String = { switch status { case .twitter: return L10n.Common.Alerts.DeleteTweetConfirm.title diff --git a/TwidereX/Protocol/Facade/DataSourceFacade+StatusThread.swift b/TwidereX/Protocol/Facade/DataSourceFacade+StatusThread.swift new file mode 100644 index 00000000..a382d3d5 --- /dev/null +++ b/TwidereX/Protocol/Facade/DataSourceFacade+StatusThread.swift @@ -0,0 +1,39 @@ +// +// DataSourceFacade+StatusThread.swift +// DataSourceFacade+StatusThread +// +// Created by Cirno MainasuK on 2021-9-6. +// Copyright © 2021 Twidere. All rights reserved. +// + +import Foundation +import CoreData +import CoreDataStack + +extension DataSourceFacade { + @MainActor + static func coordinateToStatusThreadScene( + provider: DataSourceProvider & AuthContextProvider, + kind: StatusThreadViewModel.Kind + ) async { + let statusThreadViewModel = StatusThreadViewModel( + context: provider.context, + authContext: provider.authContext, + kind: kind + ) + provider.coordinator.present( + scene: .statusThread(viewModel: statusThreadViewModel), + from: provider, + transition: .show + ) + + // FIXME: +// Task { +// guard case let .root(threadContext) = root else { return } +// await recordStatusHistory( +// denpendency: provider, +// status: threadContext.status +// ) +// } // end Task + } +} diff --git a/TwidereX/Protocol/Provider/DataSourceFacade+Translate.swift b/TwidereX/Protocol/Facade/DataSourceFacade+Translate.swift similarity index 85% rename from TwidereX/Protocol/Provider/DataSourceFacade+Translate.swift rename to TwidereX/Protocol/Facade/DataSourceFacade+Translate.swift index 3ece9937..81a4fc5d 100644 --- a/TwidereX/Protocol/Provider/DataSourceFacade+Translate.swift +++ b/TwidereX/Protocol/Facade/DataSourceFacade+Translate.swift @@ -8,7 +8,6 @@ import os.log import Foundation -import TwidereCommon import TwidereCore import MastodonMeta @@ -28,13 +27,15 @@ extension DataSourceFacade { case .mastodon(let status): let status = status.repost ?? status let spoilerText = status.spoilerText.flatMap { - Meta.convert(from: .mastodon(string: $0, emojis: [:])) + let content = MastodonContent(content: $0, emojis: [:]) + return Meta.convert(document: .mastodon(content: content)) } - let content = Meta.convert(from: .mastodon(string: status.content, emojis: [:])) + let content = MastodonContent(content: status.content, emojis: [:]) + let metaContent = Meta.convert(document: .mastodon(content: content)) return [ spoilerText?.string, - content.string + metaContent.string ] .compactMap { $0 } .joined(separator: "\n") diff --git a/TwidereX/Protocol/Provider/DataSourceFacade+User.swift b/TwidereX/Protocol/Facade/DataSourceFacade+User.swift similarity index 97% rename from TwidereX/Protocol/Provider/DataSourceFacade+User.swift rename to TwidereX/Protocol/Facade/DataSourceFacade+User.swift index c1137e77..312f1044 100644 --- a/TwidereX/Protocol/Provider/DataSourceFacade+User.swift +++ b/TwidereX/Protocol/Facade/DataSourceFacade+User.swift @@ -14,7 +14,7 @@ import TwidereCore extension DataSourceFacade { static func createMenuForUser( - provider: DataSourceProvider, + provider: DataSourceProvider & AuthContextProvider, user: UserRecord, authenticationContext: AuthenticationContext ) async throws -> UIMenu { @@ -102,7 +102,7 @@ extension DataSourceFacade { @MainActor private static func createMenuViewListsActionForUser( - provider: DataSourceProvider, + provider: DataSourceProvider & AuthContextProvider, record: UserRecord, authenticationContext: AuthenticationContext ) async -> UIAction? { @@ -138,6 +138,7 @@ extension DataSourceFacade { let compositeListViewModel = CompositeListViewModel( context: provider.context, + authContext: provider.authContext, kind: .lists(record) ) provider.coordinator.present( @@ -151,7 +152,7 @@ extension DataSourceFacade { @MainActor private static func createMenuViewListedActionForUser( - provider: DataSourceProvider, + provider: DataSourceProvider & AuthContextProvider, record: UserRecord, authenticationContext: AuthenticationContext ) async -> UIAction? { @@ -172,6 +173,7 @@ extension DataSourceFacade { let compositeListViewModel = CompositeListViewModel( context: provider.context, + authContext: provider.authContext, kind: .listed(record) ) provider.coordinator.present( @@ -313,7 +315,7 @@ extension DataSourceFacade { extension DataSourceFacade { @MainActor static func responseToUserSignOut( - dependency: NeedsDependency & UIViewController, + dependency: NeedsDependency & AuthContextProvider & UIViewController, user: UserRecord ) async throws { let alertController = UIAlertController( @@ -330,7 +332,7 @@ extension DataSourceFacade { var isSignOut = false // clear badge before sign-out - await dependency.context.notificationService.clearNotificationCountForActiveUser() + await dependency.context.notificationService.clearNotificationCountForUser(authContext: dependency.authContext) // cancel push notification subscription do { @@ -374,7 +376,6 @@ extension DataSourceFacade { guard isSignOut else { return } dependency.coordinator.setup() - dependency.coordinator.setupWelcomeIfNeeds() } // end Task } alertController.addAction(signOutAction) diff --git a/TwidereX/Protocol/Provider/DataSourceFacade.swift b/TwidereX/Protocol/Facade/DataSourceFacade.swift similarity index 100% rename from TwidereX/Protocol/Provider/DataSourceFacade.swift rename to TwidereX/Protocol/Facade/DataSourceFacade.swift diff --git a/TwidereX/Protocol/NeedsDependency+AvatarBarButtonItemDelegate.swift b/TwidereX/Protocol/NeedsDependency+AvatarBarButtonItemDelegate.swift index 0a3b4f14..de4a8a74 100644 --- a/TwidereX/Protocol/NeedsDependency+AvatarBarButtonItemDelegate.swift +++ b/TwidereX/Protocol/NeedsDependency+AvatarBarButtonItemDelegate.swift @@ -9,7 +9,7 @@ import UIKit // MARK: - AvatarBarButtonItemDelegate -extension NeedsDependency where Self: AvatarBarButtonItemDelegate { +extension NeedsDependency where Self: AvatarBarButtonItemDelegate & AuthContextProvider { func avatarBarButtonItem( _ barButtonItem: AvatarBarButtonItem, @@ -19,7 +19,7 @@ extension NeedsDependency where Self: AvatarBarButtonItemDelegate { let feedbackGenerator = UIImpactFeedbackGenerator(style: .light) feedbackGenerator.impactOccurred() - let accountListViewModel = AccountListViewModel(context: context) + let accountListViewModel = AccountListViewModel(context: context, authContext: authContext) coordinator.present( scene: .accountList(viewModel: accountListViewModel), from: nil, diff --git a/TwidereX/Protocol/Provider/DataSourceFacade+Banner.swift b/TwidereX/Protocol/Provider/DataSourceFacade+Banner.swift deleted file mode 100644 index 15af84a5..00000000 --- a/TwidereX/Protocol/Provider/DataSourceFacade+Banner.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// DataSourceFacade+Banner.swift -// TwidereX -// -// Created by MainasuK on 2022-3-22. -// Copyright © 2022 Twidere. All rights reserved. -// - -import os.log -import Foundation -import SwiftMessages - -extension DataSourceFacade { - - @MainActor - public static func presentSuccessBanner( - title: String - ) { - var config = SwiftMessages.defaultConfig - config.duration = .seconds(seconds: 3) - config.interactiveHide = true - let bannerView = NotificationBannerView() - bannerView.configure(style: .success) - bannerView.titleLabel.text = title - bannerView.messageLabel.isHidden = true - SwiftMessages.show(config: config, view: bannerView) - } - - @MainActor - public static func presentWarningBanner( - title: String, - message: String, - error: Error - ) { - var config = SwiftMessages.defaultConfig - config.duration = .seconds(seconds: 3) - config.interactiveHide = true - let bannerView = NotificationBannerView() - bannerView.configure(style: .warning) - bannerView.titleLabel.text = title - bannerView.messageLabel.text = message - SwiftMessages.show(config: config, view: bannerView) - } - -} diff --git a/TwidereX/Protocol/Provider/DataSourceFacade+Media.swift b/TwidereX/Protocol/Provider/DataSourceFacade+Media.swift deleted file mode 100644 index 6f1454f7..00000000 --- a/TwidereX/Protocol/Provider/DataSourceFacade+Media.swift +++ /dev/null @@ -1,185 +0,0 @@ -// -// DataSourceFacade+Media.swift -// TwidereX -// -// Created by MainasuK on 2021-12-7. -// Copyright © 2021 Twidere. All rights reserved. -// - -import UIKit -import AVKit -import TwidereCore -import TwidereUI - -extension DataSourceFacade { - - struct MediaPreviewContext { - // let statusView: StatusView - let containerView: ContainerView - let mediaView: MediaView - let index: Int - - enum ContainerView { - case mediaView(MediaView) - case mediaGridContainerView(MediaGridContainerView) - } - - func thumbnails() async -> [UIImage?] { - switch containerView { - case .mediaView(let mediaView): - let thumbnail = await mediaView.thumbnail() - return [thumbnail] - case .mediaGridContainerView(let mediaGridContainerView): - let thumbnails = await mediaGridContainerView.mediaViews.parallelMap { mediaView in - return await mediaView.thumbnail() - } - return thumbnails - } - } - } - - static func coordinateToMediaPreviewScene( - provider: DataSourceProvider & MediaPreviewableViewController, - target: StatusTarget, - status: StatusRecord, - mediaPreviewContext: MediaPreviewContext - ) async { - let _redirectRecord = await DataSourceFacade.status( - managedObjectContext: provider.context.managedObjectContext, - status: status, - target: target - ) - guard let redirectRecord = _redirectRecord else { return } - - await coordinateToMediaPreviewScene( - provider: provider, - status: redirectRecord, - mediaPreviewContext: mediaPreviewContext - ) - } - -} - -extension DataSourceFacade { - - @MainActor - static func coordinateToMediaPreviewScene( - provider: DataSourceProvider & MediaPreviewableViewController, - status: StatusRecord, - mediaPreviewContext: MediaPreviewContext - ) async { - let attachments: [AttachmentObject] = await provider.context.managedObjectContext.perform { - guard let status = status.object(in: provider.context.managedObjectContext) else { return [] } - return status.attachments - } - let thumbnails = await mediaPreviewContext.thumbnails() - - // use standard video player - if let first = attachments.first, first.kind == .video || first.kind == .audio { - Task { @MainActor [weak provider] in - guard let provider = provider else { return } - // workaround Twitter Video assertURL missing from V2 API issue - var assetURL: URL - if let url = first.assetURL { - assetURL = url - } else if case let .twitter(record) = status { - let _statusID: String? = await provider.context.managedObjectContext.perform { - let status = record.object(in: provider.context.managedObjectContext) - return status?.id - } - guard let statusID = _statusID, - case let .twitter(authenticationContext) = provider.context.authenticationService.activeAuthenticationContext - else { return } - - let _response = try? await provider.context.apiService.twitterStatusV1(statusIDs: [statusID], authenticationContext: authenticationContext) - guard let status = _response?.value.first, - let url = status.extendedEntities?.media?.first?.assetURL.flatMap({ URL(string: $0) }) - else { return } - assetURL = url - } else { - assertionFailure() - return - } - let playerViewController = AVPlayerViewController() - playerViewController.player = AVPlayer(url: assetURL) - playerViewController.player?.play() - playerViewController.delegate = provider.context.playerService - provider.present(playerViewController, animated: true, completion: nil) - } // end Task - return - } - - let source: MediaPreviewTransitionItem.Source = { - switch mediaPreviewContext.containerView { - case .mediaView(let mediaView): - return .attachment(mediaView) - case .mediaGridContainerView(let mediaGridContainerView): - return .attachments(mediaGridContainerView) - } - }() - - await coordinateToMediaPreviewScene( - provider: provider, - status: status, - mediaPreviewItem: .statusAttachment(.init( - status: status, - attachments: attachments, - initialIndex: mediaPreviewContext.index, - preloadThumbnails: thumbnails - )), - mediaPreviewTransitionItem: { - // FIXME: allow other source - let item = MediaPreviewTransitionItem( - source: source, - previewableViewController: provider - ) - let mediaView = mediaPreviewContext.mediaView - - item.initialFrame = { - let initialFrame = mediaView.superview!.convert(mediaView.frame, to: nil) - assert(initialFrame != .zero) - return initialFrame - }() - - let thumbnail = mediaView.thumbnail() - item.image = thumbnail - - item.aspectRatio = { - if let thumbnail = thumbnail { - return thumbnail.size - } - let index = mediaPreviewContext.index - guard index < attachments.count else { return nil } - let size = attachments[index].size - return size - }() - - item.sourceImageViewCornerRadius = MediaView.cornerRadius - - return item - }(), - mediaPreviewContext: mediaPreviewContext - ) - } - - @MainActor - static func coordinateToMediaPreviewScene( - provider: DataSourceProvider & MediaPreviewableViewController, - status: StatusRecord, - mediaPreviewItem: MediaPreviewViewModel.Item, - mediaPreviewTransitionItem: MediaPreviewTransitionItem, - mediaPreviewContext: MediaPreviewContext - ) async { - let mediaPreviewViewModel = MediaPreviewViewModel( - context: provider.context, - item: mediaPreviewItem, - transitionItem: mediaPreviewTransitionItem - ) - provider.coordinator.present( - scene: .mediaPreview(viewModel: mediaPreviewViewModel), - from: provider, - transition: .custom(transitioningDelegate: provider.mediaPreviewTransitionController) - ) - } - -} diff --git a/TwidereX/Protocol/Provider/DataSourceFacade+StatusThread.swift b/TwidereX/Protocol/Provider/DataSourceFacade+StatusThread.swift deleted file mode 100644 index b8cc3519..00000000 --- a/TwidereX/Protocol/Provider/DataSourceFacade+StatusThread.swift +++ /dev/null @@ -1,76 +0,0 @@ -// -// DataSourceFacade+StatusThread.swift -// DataSourceFacade+StatusThread -// -// Created by Cirno MainasuK on 2021-9-6. -// Copyright © 2021 Twidere. All rights reserved. -// - -import Foundation -import CoreData -import CoreDataStack - -extension DataSourceFacade { - static func coordinateToStatusThreadScene( - provider: DataSourceProvider, - target: StatusTarget, - status: StatusRecord - ) async { - let _root: StatusItem.Thread? = await { - let _redirectRecord = await DataSourceFacade.status( - managedObjectContext: provider.context.managedObjectContext, - status: status, - target: target - ) - guard let redirectRecord = _redirectRecord else { return nil } - - switch redirectRecord { - case .twitter(let record): - let context = StatusItem.Thread.Context( - status: .twitter(record: record), - displayUpperConversationLink: await provider.context.managedObjectContext.perform { - guard let status = record.object(in: provider.context.managedObjectContext) else { return false } - return status.replyToStatusID != nil - }, - displayBottomConversationLink: false - ) - return StatusItem.Thread.root(context: context) - case .mastodon(let record): - let context = StatusItem.Thread.Context( - status: .mastodon(record: record), - displayUpperConversationLink: await provider.context.managedObjectContext.perform { - guard let status = record.object(in: provider.context.managedObjectContext) else { return false } - return status.replyToStatusID != nil - }, - displayBottomConversationLink: false - ) - return StatusItem.Thread.root(context: context) - } - }() - guard let root = _root else { - assertionFailure() - return - } - - await coordinateToStatusThreadScene( - provider: provider, - root: root - ) - } - - @MainActor - static func coordinateToStatusThreadScene( - provider: DataSourceProvider, - root: StatusItem.Thread - ) async { - let statusThreadViewModel = StatusThreadViewModel( - context: provider.context, - root: root - ) - provider.coordinator.present( - scene: .statusThread(viewModel: statusThreadViewModel), - from: provider, - transition: .show - ) - } -} diff --git a/TwidereX/Protocol/Provider/DataSourceProvider+MediaInfoDescriptionViewDelegate.swift b/TwidereX/Protocol/Provider/DataSourceProvider+MediaInfoDescriptionViewDelegate.swift index a92a905f..8f07221c 100644 --- a/TwidereX/Protocol/Provider/DataSourceProvider+MediaInfoDescriptionViewDelegate.swift +++ b/TwidereX/Protocol/Provider/DataSourceProvider+MediaInfoDescriptionViewDelegate.swift @@ -8,13 +8,10 @@ import UIKit import MetaTextArea -import TwidereCommon -import TwidereCore -import TwidereUI -import AppShared import MetaTextKit +import MetaLabel -extension MediaInfoDescriptionViewDelegate where Self: DataSourceProvider { +extension MediaInfoDescriptionViewDelegate where Self: DataSourceProvider & AuthContextProvider { func mediaInfoDescriptionView(_ mediaInfoDescriptionView: MediaInfoDescriptionView, avatarButtonDidPressed button: UIButton) { Task { let source = DataSourceItem.Source(tableViewCell: nil, indexPath: nil) @@ -44,11 +41,10 @@ extension MediaInfoDescriptionViewDelegate where Self: DataSourceProvider { assertionFailure("only works for status data provider") return } - + await DataSourceFacade.coordinateToStatusThreadScene( provider: self, - target: .repost, // keep repost wrapper - status: status + kind: .status(status) ) } } @@ -63,41 +59,39 @@ extension MediaInfoDescriptionViewDelegate where Self: DataSourceProvider { assertionFailure("only works for status data provider") return } - + await DataSourceFacade.coordinateToStatusThreadScene( provider: self, - target: .repost, // keep repost wrapper - status: status + kind: .status(status) ) } } - func mediaInfoDescriptionView(_ mediaInfoDescriptionView: MediaInfoDescriptionView, statusToolbar: StatusToolbar, actionDidPressed action: StatusToolbar.Action, button: UIButton) { - guard let authenticationContext = context.authenticationService.activeAuthenticationContext else { return } - Task { - let source = DataSourceItem.Source(tableViewCell: nil, 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 - } - - await DataSourceFacade.responseToStatusToolbar( - provider: self, - status: status, - action: action, - sender: button, - authenticationContext: authenticationContext - ) - } // end Task - } // end func +// func mediaInfoDescriptionView(_ mediaInfoDescriptionView: MediaInfoDescriptionView, statusToolbar: StatusToolbar, actionDidPressed action: StatusToolbar.Action, button: UIButton) { +// Task { +// let source = DataSourceItem.Source(tableViewCell: nil, 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 +// } +// +// await DataSourceFacade.responseToStatusToolbar( +// provider: self, +// status: status, +// action: action, +// sender: button, +// authenticationContext: authContext.authenticationContext +// ) +// } // end Task +// } // end func - func mediaInfoDescriptionView(_ mediaInfoDescriptionView: MediaInfoDescriptionView, statusToolbar: StatusToolbar, menuActionDidPressed action: StatusToolbar.MenuAction, menuButton button: UIButton) { - assertionFailure("present UIAcitivityController directly") - } +// func mediaInfoDescriptionView(_ mediaInfoDescriptionView: MediaInfoDescriptionView, statusToolbar: StatusToolbar, menuActionDidPressed action: StatusToolbar.MenuAction, menuButton button: UIButton) { +// assertionFailure("present UIAcitivityController directly") +// } } diff --git a/TwidereX/Protocol/Provider/DataSourceProvider+StatusViewTableViewCellDelegate.swift b/TwidereX/Protocol/Provider/DataSourceProvider+StatusViewTableViewCellDelegate.swift index cad39dd9..c49e27ac 100644 --- a/TwidereX/Protocol/Provider/DataSourceProvider+StatusViewTableViewCellDelegate.swift +++ b/TwidereX/Protocol/Provider/DataSourceProvider+StatusViewTableViewCellDelegate.swift @@ -7,467 +7,432 @@ // import UIKit -import AppShared -import TwidereUI import MetaTextArea import Meta // MARK: - header -extension StatusViewTableViewCellDelegate where Self: DataSourceProvider { - func tableViewCell( - _ cell: UITableViewCell, - statusView: StatusView, - headerDidPressed header: UIView - ) { - 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 - } - await DataSourceFacade.coordinateToProfileScene( - provider: self, - target: .repost, - status: status - ) - } - } - +extension StatusViewTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider { +// func tableViewCell( +// _ cell: UITableViewCell, +// statusView: StatusView, +// headerDidPressed header: UIView +// ) { +// 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 +// } +// await DataSourceFacade.coordinateToProfileScene( +// provider: self, +// target: .repost, +// status: status +// ) +// } +// } } // MARK: - avatar button -extension StatusViewTableViewCellDelegate where Self: DataSourceProvider { - +extension StatusViewTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider { func tableViewCell( _ cell: UITableViewCell, - statusView: StatusView, - authorAvatarButtonDidPressed button: AvatarButton + viewModel: TwidereUI.StatusView.ViewModel, + userAvatarButtonDidPressed user: TwidereCore.UserRecord ) { 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 - } await DataSourceFacade.coordinateToProfileScene( provider: self, - target: .status, - status: status - ) - } - } - - func tableViewCell( - _ cell: UITableViewCell, - statusView: StatusView, - quoteStatusView: StatusView, - authorAvatarButtonDidPressed button: AvatarButton - ) { - 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 - } - await DataSourceFacade.coordinateToProfileScene( - provider: self, - target: .quote, - status: status - ) - } - } - -} - -// MARK: - spoiler -extension StatusViewTableViewCellDelegate where Self: DataSourceProvider { - func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, expandContentButtonDidPressed button: UIButton) { - 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.responseToExpandContentAction( - provider: self, - target: .status, - status: status + user: user ) } // end Task } } // MARK: - content -extension StatusViewTableViewCellDelegate where Self: DataSourceProvider { - func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, metaTextAreaView: MetaTextAreaView, didSelectMeta meta: Meta) { - 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 - } - - await DataSourceFacade.responseToMetaTextAreaView( +extension StatusViewTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider { + func tableViewCell(_ cell: UITableViewCell, viewModel: StatusView.ViewModel, toggleContentDisplay isReveal: Bool) { + Task { @MainActor in + guard let status = viewModel.status?.asRecord else { return } + + try await DataSourceFacade.responseToToggleContentSensitiveAction( provider: self, target: .status, - status: status, - metaTextAreaView: metaTextAreaView, - didSelectMeta: meta + status: status ) - } + } // end Task } - func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, quoteStatusView: StatusView, metaTextAreaView: MetaTextAreaView, didSelectMeta meta: Meta) { - 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 + func tableViewCell(_ cell: UITableViewCell, viewModel: StatusView.ViewModel, textViewDidSelectMeta meta: Meta?) { + Task { @MainActor in + guard let status = viewModel.status?.asRecord else { return } + + guard let meta = meta else { + switch viewModel.kind { + case .conversationRoot: + return + default: + await DataSourceFacade.coordinateToStatusThreadScene( + provider: self, + kind: .status(status) + ) + return + } } - await DataSourceFacade.responseToMetaTextAreaView( + await DataSourceFacade.responseToMetaText( provider: self, - target: .quote, status: status, - metaTextAreaView: metaTextAreaView, didSelectMeta: meta ) - } + } // end Task } } - // MARK: - media -extension StatusViewTableViewCellDelegate where Self: DataSourceProvider & MediaPreviewableViewController { +extension StatusViewTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider & MediaPreviewableViewController { + @MainActor func tableViewCell( _ cell: UITableViewCell, - statusView: StatusView, - mediaGridContainerView containerView: MediaGridContainerView, - didTapMediaView mediaView: MediaView, - at index: Int + viewModel: StatusView.ViewModel, + mediaViewModel: MediaView.ViewModel, + action: MediaView.ViewModel.Action + ) { + assert(Thread.isMainThread) + DataSourceFacade.responseToMediaViewAction( + provider: self, + statusViewModel: viewModel, + mediaViewModel: mediaViewModel, + action: action + ) + } + + func tableViewCell( + _ cell: UITableViewCell, + viewModel: StatusView.ViewModel, + toggleContentWarningOverlayDisplay isReveal: Bool ) { 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 - } // end switch - - await DataSourceFacade.coordinateToMediaPreviewScene( + guard let status = viewModel.status?.asRecord else { return } + try await DataSourceFacade.responseToToggleMediaSensitiveAction( provider: self, target: .status, - status: status, - mediaPreviewContext: DataSourceFacade.MediaPreviewContext( - containerView: .mediaGridContainerView(containerView), - mediaView: mediaView, - index: index - ) + status: status ) } // end Task } - +} + +// MARK: - poll +extension StatusViewTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider { func tableViewCell( _ cell: UITableViewCell, - statusView: StatusView, - quoteStatusView: StatusView, - mediaGridContainerView containerView: MediaGridContainerView, - didTapMediaView mediaView: MediaView, - at index: Int + viewModel: StatusView.ViewModel, + pollVoteActionForViewModel pollViewModel: PollView.ViewModel ) { 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 - } - await DataSourceFacade.coordinateToMediaPreviewScene( + guard let status = viewModel.status?.asRecord else { return } + try await DataSourceFacade.responseToStatusPollVote( provider: self, - target: .quote, - status: status, - mediaPreviewContext: DataSourceFacade.MediaPreviewContext( - containerView: .mediaGridContainerView(containerView), - mediaView: mediaView, - index: index - ) + status: status ) - } + } // end Task } - func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, mediaGridContainerView containerView: MediaGridContainerView, toggleContentWarningOverlayViewDisplay contentWarningOverlayView: ContentWarningOverlayView) { + func tableViewCell( + _ cell: UITableViewCell, + viewModel: StatusView.ViewModel, + pollUpdateIfNeedsForViewModel pollViewModel: PollView.ViewModel + ) { Task { - let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) - guard let item = await item(from: source) else { - assertionFailure() + guard await pollViewModel.needsUpdate else { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Poll] not needs update. skip") return } - guard let status = await item.status(in: self.context.managedObjectContext) else { assertionFailure("only works for status data provider") - return - } - try await DataSourceFacade.responseToToggleMediaSensitiveAction( + guard let status = viewModel.status?.asRecord else { return } + try await DataSourceFacade.responseToStatusPollUpdate( provider: self, - target: .status, status: status ) - } + } // end Task } -} -// poll -extension StatusViewTableViewCellDelegate where Self: DataSourceProvider { - func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, pollTableView tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - 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 - } - await DataSourceFacade.responseToStatusPollOption( - provider: self, - target: .status, - status: status, - didSelectRowAt: indexPath - ) - } - } - func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, pollVoteButtonDidPressed button: UIButton) { + func tableViewCell( + _ cell: UITableViewCell, + viewModel: StatusView.ViewModel, + pollViewModel: PollView.ViewModel, + pollOptionDidSelectForViewModel optionViewModel: PollOptionView.ViewModel + ) { 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 - } + guard let status = viewModel.status?.asRecord else { return } await DataSourceFacade.responseToStatusPollOption( provider: self, target: .status, status: status, - voteButtonDidPressed: button + didSelectRowAt: optionViewModel.index ) - } + } // end Task } + +// func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, pollTableView tableView: UITableView, didSelectRowAt indexPath: IndexPath) { +// 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 +// } +// await DataSourceFacade.responseToStatusPollOption( +// provider: self, +// target: .status, +// status: status, +// didSelectRowAt: indexPath +// ) +// } +// } +// +// func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, pollVoteButtonDidPressed button: UIButton) { +// 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 +// } +// await DataSourceFacade.responseToStatusPollOption( +// provider: self, +// target: .status, +// status: status, +// voteButtonDidPressed: button +// ) +// } +// } } // MARK: - quote -extension StatusViewTableViewCellDelegate where Self: DataSourceProvider { - func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, quoteStatusViewDidPressed quoteStatusView: StatusView) { +extension StatusViewTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider { + func tableViewCell( + _ cell: UITableViewCell, + viewModel: StatusView.ViewModel, + quoteStatusViewDidPressed quoteViewModel: StatusView.ViewModel + ) { + guard let status = quoteViewModel.status?.asRecord else { return } 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 + kind: .status(status) ) - } + } // end Task } } +// MARK: - metric +extension StatusViewTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider { + func tableViewCell( + _ cell: UITableViewCell, + viewModel: StatusView.ViewModel, + statusMetricViewModel: StatusMetricView.ViewModel, + statusMetricButtonDidPressed action: StatusMetricView.Action + ) { + Task { + guard let status = viewModel.status?.asRecord else { return } + // TODO: + } // end Task + } +} // MARK: - toolbar -extension StatusViewTableViewCellDelegate where Self: DataSourceProvider { +extension StatusViewTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider { func tableViewCell( _ cell: UITableViewCell, - statusView: StatusView, - statusToolbar: StatusToolbar, - actionDidPressed action: StatusToolbar.Action, - button: UIButton + viewModel: StatusView.ViewModel, + statusToolbarViewModel: StatusToolbarView.ViewModel, + statusToolbarButtonDidPressed action: StatusToolbarView.Action ) { - guard let authenticationContext = context.authenticationService.activeAuthenticationContext else { return } 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 - } - + guard let status = viewModel.status?.asRecord else { return } await DataSourceFacade.responseToStatusToolbar( provider: self, + viewModel: viewModel, + statusToolbarViewModel: statusToolbarViewModel, status: status, - action: action, - sender: button, - authenticationContext: authenticationContext + action: action ) } // end Task - } // end func - - func tableViewCell( - _ cell: UITableViewCell, - statusView: StatusView, - statusToolbar: StatusToolbar, - menuActionDidPressed action: StatusToolbar.MenuAction, - menuButton button: UIButton - ) { - 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 - } + } - switch action { - case .saveMedia: - let mediaViewConfigurations = await statusView.viewModel.mediaViewConfigurations - let impactFeedbackGenerator = await UIImpactFeedbackGenerator(style: .light) - let notificationFeedbackGenerator = await UINotificationFeedbackGenerator() +// func tableViewCell( +// _ cell: UITableViewCell, +// statusView: StatusView, +// statusToolbar: StatusToolbar, +// menuActionDidPressed action: StatusToolbar.MenuAction, +// menuButton button: UIButton +// ) { +// 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 +// } +// +// switch action { +// case .saveMedia: +// let mediaViewConfigurations = await statusView.viewModel.mediaViewConfigurations +// let impactFeedbackGenerator = await UIImpactFeedbackGenerator(style: .light) +// let notificationFeedbackGenerator = await UINotificationFeedbackGenerator() +// +// do { +// await impactFeedbackGenerator.impactOccurred() +// for configuration in mediaViewConfigurations { +// guard let url = configuration.downloadURL.flatMap({ URL(string: $0) }) else { continue } +// try await context.photoLibraryService.save(source: .remote(url: url), resourceType: configuration.resourceType) +// } +// await context.photoLibraryService.presentSuccessNotification(title: L10n.Common.Alerts.PhotoSaved.title) +// await notificationFeedbackGenerator.notificationOccurred(.success) +// } catch { +// await context.photoLibraryService.presentFailureNotification( +// error: error, +// title: L10n.Common.Alerts.PhotoSaveFail.title, +// message: L10n.Common.Alerts.PhotoSaveFail.message +// ) +// await notificationFeedbackGenerator.notificationOccurred(.error) +// } +// case .translate: +// try await DataSourceFacade.responseToStatusTranslate( +// provider: self, +// status: status +// ) +// case .share: +// await DataSourceFacade.responseToStatusShareAction( +// provider: self, +// status: status, +// button: button +// ) +// case .remove: +// try await DataSourceFacade.responseToRemoveStatusAction( +// provider: self, +// target: .status, +// status: status, +// authenticationContext: self.authContext.authenticationContext +// ) +// #if DEBUG +// case .copyID: +// let _statusID: String? = await context.managedObjectContext.perform { +// guard let status = status.object(in: self.context.managedObjectContext) else { return nil } +// return status.id +// } +// if let statusID = _statusID { +// UIPasteboard.general.string = statusID +// } +// #endif +// case .appearEvent: +// let _record = await DataSourceFacade.status( +// managedObjectContext: context.managedObjectContext, +// status: status, +// target: .status +// ) +// guard let record = _record else { +// return +// } +// +// await DataSourceFacade.recordStatusHistory( +// denpendency: self, +// status: record +// ) +// } // end switch +// } // end Task +// } // end func - do { - await impactFeedbackGenerator.impactOccurred() - for configuration in mediaViewConfigurations { - guard let url = configuration.downloadURL.flatMap({ URL(string: $0) }) else { continue } - try await context.photoLibraryService.save(source: .remote(url: url), resourceType: configuration.resourceType) - } - await context.photoLibraryService.presentSuccessNotification(title: L10n.Common.Alerts.PhotoSaved.title) - await notificationFeedbackGenerator.notificationOccurred(.success) - } catch { - await context.photoLibraryService.presentFailureNotification( - error: error, - title: L10n.Common.Alerts.PhotoSaveFail.title, - message: L10n.Common.Alerts.PhotoSaveFail.message - ) - await notificationFeedbackGenerator.notificationOccurred(.error) - } - case .translate: - try await DataSourceFacade.responseToStatusTranslate( - provider: self, - status: status - ) - case .share: - await DataSourceFacade.responseToStatusShareAction( - provider: self, - status: status, - button: button - ) - case .remove: - guard let authenticationContext = context.authenticationService.activeAuthenticationContext else { return } - try await DataSourceFacade.responseToRemoveStatusAction( - provider: self, - target: .status, - status: status, - authenticationContext: authenticationContext - ) - #if DEBUG - case .copyID: - let _statusID: String? = await context.managedObjectContext.perform { - guard let status = status.object(in: self.context.managedObjectContext) else { return nil } - return status.id - } - if let statusID = _statusID { - UIPasteboard.general.string = statusID - } - #endif - } // end switch - } // end Task - } // end func +} +extension StatusViewTableViewCellDelegate where Self: DataSourceProvider { +// func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, translateButtonDidPressed button: UIButton) { +// 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 +// } +// +// try await DataSourceFacade.responseToStatusTranslate( +// provider: self, +// status: status +// ) +// } // end Task +// } } extension StatusViewTableViewCellDelegate where Self: DataSourceProvider { - func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, translateButtonDidPressed button: UIButton) { - 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 - } - - try await DataSourceFacade.responseToStatusTranslate( - provider: self, - status: status - ) - } // end Task + func tableViewCell(_ cell: UITableViewCell, viewModel: StatusView.ViewModel, viewHeightDidChange: Void) { + // Manually update self resize + UIView.performWithoutAnimation { + cell.invalidateIntrinsicContentSize() + } } } // MARK: - a11y -extension StatusViewTableViewCellDelegate where Self: DataSourceProvider { +extension StatusViewTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider { - func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, accessibilityActivate: Void) { - Task { - let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) - guard let item = await item(from: source) else { - return - } - switch item { - case .status(let status): - await DataSourceFacade.coordinateToStatusThreadScene( - provider: self, - target: .repost, // keep repost wrapper - status: status - ) - case .user(let user): - await DataSourceFacade.coordinateToProfileScene( - provider: self, - user: user - ) - case .notification(let notification): - let managedObjectContext = self.context.managedObjectContext - guard let object = notification.object(in: managedObjectContext) else { - assertionFailure() - return - } - switch object { - case .mastodon(let notification): - if let status = notification.status { - await DataSourceFacade.coordinateToStatusThreadScene( - provider: self, - target: .repost, // keep repost wrapper - status: .mastodon(record: .init(objectID: status.objectID)) - ) - } else { - await DataSourceFacade.coordinateToProfileScene( - provider: self, - user: .mastodon(record: .init(objectID: notification.account.objectID)) - ) - } - } - } - } // end Task - } // end func +// func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, accessibilityActivate: Void) { +// Task { +// let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) +// guard let item = await item(from: source) else { +// return +// } +// switch item { +// case .status(let status): +// await DataSourceFacade.coordinateToStatusThreadScene( +// provider: self, +// target: .repost, // keep repost wrapper +// status: status +// ) +// case .user(let user): +// await DataSourceFacade.coordinateToProfileScene( +// provider: self, +// user: user +// ) +// case .notification(let notification): +// let managedObjectContext = self.context.managedObjectContext +// guard let object = notification.object(in: managedObjectContext) else { +// assertionFailure() +// return +// } +// switch object { +// case .mastodon(let notification): +// if let status = notification.status { +// await DataSourceFacade.coordinateToStatusThreadScene( +// provider: self, +// target: .repost, // keep repost wrapper +// status: .mastodon(record: .init(objectID: status.objectID)) +// ) +// } else { +// await DataSourceFacade.coordinateToProfileScene( +// provider: self, +// user: .mastodon(record: .init(objectID: notification.account.objectID)) +// ) +// } +// } +// } +// } // end Task +// } // end func } diff --git a/TwidereX/Protocol/Provider/DataSourceProvider+UITableViewDataSourcePrefetching.swift b/TwidereX/Protocol/Provider/DataSourceProvider+UITableViewDataSourcePrefetching.swift new file mode 100644 index 00000000..e67d5457 --- /dev/null +++ b/TwidereX/Protocol/Provider/DataSourceProvider+UITableViewDataSourcePrefetching.swift @@ -0,0 +1,33 @@ +// +// DataSourceProvider+UITableViewDataSourcePrefetching.swift +// TwidereX +// +// Created by MainasuK on 2023/4/7. +// Copyright © 2023 Twidere. All rights reserved. +// + +import UIKit +import CollectionConcurrencyKit + +extension UITableViewDelegate where Self: DataSourceProvider { + func aspectTableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { + Task { +// let itmes: [DataSourceItem] = await indexPaths +// .concurrentCompactMap { [weak self] indexPath -> DataSourceItem? in +// guard let self = self else { return nil } +// return await self.item(from: .init(indexPath: indexPath)) +// } +// +// var statusRecords: [StatusRecord] = [] +// var userRecords: [UserRecord] = [] +// for item in itmes { +// switch item { +// case .status(let record): statusRecords.append(record) +// case .user(let record): userRecords.append(record) +// case .notification: +// continue +// } +// } + } // end Task + } +} diff --git a/TwidereX/Protocol/Provider/DataSourceProvider+UITableViewDelegate.swift b/TwidereX/Protocol/Provider/DataSourceProvider+UITableViewDelegate.swift index fe89c78f..4ff91370 100644 --- a/TwidereX/Protocol/Provider/DataSourceProvider+UITableViewDelegate.swift +++ b/TwidereX/Protocol/Provider/DataSourceProvider+UITableViewDelegate.swift @@ -7,12 +7,17 @@ // import UIKit -import TwidereUI +import Photos -extension UITableViewDelegate where Self: DataSourceProvider { +extension UITableViewDelegate where Self: DataSourceProvider & AuthContextProvider { func aspectTableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): indexPath: \(indexPath.debugDescription)") + + // TODO: tweak cell selection background color + // let cell = tableView.cellForRow(at: indexPath) + // cell?.backgroundColor = .red + Task { let source = DataSourceItem.Source(tableViewCell: nil, indexPath: indexPath) guard let item = await item(from: source) else { @@ -22,8 +27,7 @@ extension UITableViewDelegate where Self: DataSourceProvider { case .status(let status): await DataSourceFacade.coordinateToStatusThreadScene( provider: self, - target: .repost, // keep repost wrapper - status: status + kind: .status(status) ) case .user(let user): await DataSourceFacade.coordinateToProfileScene( @@ -31,23 +35,27 @@ extension UITableViewDelegate where Self: DataSourceProvider { user: user ) case .notification(let notification): - let managedObjectContext = self.context.managedObjectContext - guard let object = notification.object(in: managedObjectContext) else { - assertionFailure() - return - } - switch object { + switch notification { + case .twitter(let status): + await DataSourceFacade.coordinateToStatusThreadScene( + provider: self, + kind: .status(.twitter(record: status)) + ) case .mastodon(let notification): - if let status = notification.status { + let managedObjectContext = self.context.managedObjectContext + guard let object = notification.object(in: managedObjectContext) else { + assertionFailure() + return + } + if let status = object.status { await DataSourceFacade.coordinateToStatusThreadScene( provider: self, - target: .repost, // keep repost wrapper - status: .mastodon(record: .init(objectID: status.objectID)) + kind: .status(.mastodon(record: status.asRecrod)) ) } else { await DataSourceFacade.coordinateToProfileScene( provider: self, - user: .mastodon(record: .init(objectID: notification.account.objectID)) + user: .mastodon(record: object.account.asRecrod) ) } } @@ -57,168 +65,10 @@ extension UITableViewDelegate where Self: DataSourceProvider { } -extension UITableViewDelegate where Self: DataSourceProvider & MediaPreviewableViewController { - +extension UITableViewDelegate where Self: DataSourceProvider & AuthContextProvider & MediaPreviewableViewController { + func aspectTableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") - - guard let cell = tableView.cellForRow(at: indexPath) as? StatusViewContainerTableViewCell else { return nil } - - // TODO: - // this must call before check `isContentWarningOverlayDisplay`. otherwise, will get BadAccess exception - let mediaViews = cell.statusView.mediaGridContainerView.mediaViews - - if cell.statusView.mediaGridContainerView.viewModel.isContentWarningOverlayDisplay == true { - return nil - } - - for (i, mediaView) in mediaViews.enumerated() { - let pointInMediaView = mediaView.convert(point, from: tableView) - guard mediaView.point(inside: pointInMediaView, with: nil) else { - continue - } - guard let image = mediaView.thumbnail(), - let assetURLString = mediaView.configuration?.downloadURL, - let assetURL = URL(string: assetURLString), - let resourceType = mediaView.configuration?.resourceType - else { - // not provide preview unless thumbnail ready - return nil - } - - let contextMenuImagePreviewViewModel = ContextMenuImagePreviewViewModel(aspectRatio: image.size, thumbnail: image) - - let configuration = TimelineTableViewCellContextMenuConfiguration(identifier: nil) { () -> UIViewController? in - if UIDevice.current.userInterfaceIdiom == .pad && mediaViews.count == 1 { - return nil - } - let previewProvider = ContextMenuImagePreviewViewController() - previewProvider.viewModel = contextMenuImagePreviewViewModel - return previewProvider - - } actionProvider: { _ -> UIMenu? in - return UIMenu( - title: "", - image: nil, - identifier: nil, - options: [], - children: [ - UIAction( - title: L10n.Common.Controls.Actions.save, - image: UIImage(systemName: "square.and.arrow.down"), - attributes: [], - state: .off - ) { [weak self] _ in - guard let self = self else { return } - Task { @MainActor in - let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) - let notificationFeedbackGenerator = UINotificationFeedbackGenerator() - - do { - impactFeedbackGenerator.impactOccurred() - try await self.context.photoLibraryService.save( - source: .remote(url: assetURL), - resourceType: resourceType - ) - self.context.photoLibraryService.presentSuccessNotification(title: L10n.Common.Alerts.PhotoSaved.title) - notificationFeedbackGenerator.notificationOccurred(.success) - } catch { - self.context.photoLibraryService.presentFailureNotification( - error: error, - title: L10n.Common.Alerts.PhotoSaveFail.title, - message: L10n.Common.Alerts.PhotoSaveFail.message - ) - notificationFeedbackGenerator.notificationOccurred(.error) - } - } // end Task - }, - UIAction( - title: L10n.Common.Controls.Actions.copy, - image: UIImage(systemName: "doc.on.doc"), - attributes: [], - state: .off - ) { [weak self] _ in - guard let self = self else { return } - Task { @MainActor in - let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) - let notificationFeedbackGenerator = UINotificationFeedbackGenerator() - - do { - impactFeedbackGenerator.impactOccurred() - try await self.context.photoLibraryService.copy( - source: .remote(url: assetURL), - resourceType: resourceType - ) - self.context.photoLibraryService.presentSuccessNotification(title: L10n.Common.Alerts.PhotoCopied.title) - notificationFeedbackGenerator.notificationOccurred(.success) - } catch { - self.context.photoLibraryService.presentFailureNotification( - error: error, - title: L10n.Common.Alerts.PhotoCopied.title, - message: L10n.Common.Alerts.PhotoCopyFail.message - ) - notificationFeedbackGenerator.notificationOccurred(.error) - } - } // end Task - }, - UIMenu( - title: L10n.Common.Controls.Actions.share, - image: UIImage(systemName: "square.and.arrow.up"), - identifier: nil, - options: [], - children: [ - UIAction( - title: L10n.Common.Controls.Actions.ShareMediaMenu.link, - image: UIImage(systemName: "link"), - attributes: [], - state: .off - ) { [weak self] _ in - guard let self = self else { return } - Task { @MainActor in - let applicationActivities: [UIActivity] = [ - SafariActivity(sceneCoordinator: self.coordinator) - ] - let activityViewController = UIActivityViewController( - activityItems: [assetURL], - applicationActivities: applicationActivities - ) - activityViewController.popoverPresentationController?.sourceView = mediaView - self.present(activityViewController, animated: true, completion: nil) - } // end Task - }, - UIAction( - title: L10n.Common.Controls.Actions.ShareMediaMenu.media, - image: UIImage(systemName: "photo"), - attributes: [], - state: .off - ) { [weak self] _ in - guard let self = self else { return } - Task { @MainActor in - let applicationActivities: [UIActivity] = [ - SafariActivity(sceneCoordinator: self.coordinator) - ] - // FIXME: handle error - guard let url = try await self.context.photoLibraryService.file(from: .remote(url: assetURL)) else { - return - } - let activityViewController = UIActivityViewController( - activityItems: [url], - applicationActivities: applicationActivities - ) - activityViewController.popoverPresentationController?.sourceView = mediaView - self.present(activityViewController, animated: true, completion: nil) - } // end Task - }, - ] - ), - ] // end children - ) // end return UIMenu - } - configuration.indexPath = indexPath - configuration.index = i - return configuration - } // end for … in … - return nil } @@ -243,19 +93,7 @@ extension UITableViewDelegate where Self: DataSourceProvider & MediaPreviewableV _ tableView: UITableView, configuration: UIContextMenuConfiguration ) -> UITargetedPreview? { - guard let configuration = configuration as? TimelineTableViewCellContextMenuConfiguration else { return nil } - guard let indexPath = configuration.indexPath, let index = configuration.index else { return nil } - if let cell = tableView.cellForRow(at: indexPath) as? StatusViewContainerTableViewCell { - let mediaViews = cell.statusView.mediaGridContainerView.mediaViews - guard index < mediaViews.count else { return nil } - let mediaView = mediaViews[index] - let parameters = UIPreviewParameters() - parameters.backgroundColor = .clear - parameters.visiblePath = UIBezierPath(roundedRect: mediaView.bounds, cornerRadius: MediaView.cornerRadius) - return UITargetedPreview(view: mediaView, parameters: parameters) - } else { - return nil - } + return nil } func aspectTableView( @@ -264,37 +102,6 @@ extension UITableViewDelegate where Self: DataSourceProvider & MediaPreviewableV animator: UIContextMenuInteractionCommitAnimating ) { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") - guard let configuration = configuration as? TimelineTableViewCellContextMenuConfiguration else { return } - guard let indexPath = configuration.indexPath, let index = configuration.index else { return } - guard let cell = tableView.cellForRow(at: indexPath) as? StatusViewContainerTableViewCell else { return } - let mediaViews = cell.statusView.mediaGridContainerView.mediaViews - guard index < mediaViews.count else { return } - let mediaView = mediaViews[index] - - animator.addCompletion { - Task { [weak self] in - guard let self = self else { return } - let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) - guard let item = await self.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 - } - await DataSourceFacade.coordinateToMediaPreviewScene( - provider: self, - target: .status, - status: status, - mediaPreviewContext: DataSourceFacade.MediaPreviewContext( - containerView: .mediaGridContainerView(cell.statusView.mediaGridContainerView), - mediaView: mediaView, - index: index - ) - ) - } // end Task - } } // end func } diff --git a/TwidereX/Protocol/Provider/DataSourceProvider+UserViewTableViewCellDelegate.swift b/TwidereX/Protocol/Provider/DataSourceProvider+UserViewTableViewCellDelegate.swift index 48802925..d16ad04f 100644 --- a/TwidereX/Protocol/Provider/DataSourceProvider+UserViewTableViewCellDelegate.swift +++ b/TwidereX/Protocol/Provider/DataSourceProvider+UserViewTableViewCellDelegate.swift @@ -7,90 +7,121 @@ // import UIKit -import TwidereUI +import CoreData +import CoreDataStack import SwiftMessages +// MARK: - avatar button +extension UserViewTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider { + func tableViewCell( + _ cell: UITableViewCell, + viewModel: UserView.ViewModel, + userAvatarButtonDidPressed user: UserRecord + ) { + Task { + await DataSourceFacade.coordinateToProfileScene( + provider: self, + user: user + ) + } // end Task + } +} + + // MARK: - menu button -extension UserViewTableViewCellDelegate where Self: DataSourceProvider { - +extension UserViewTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider { func tableViewCell( _ cell: UITableViewCell, - userView: UserView, - menuActionDidPressed action: UserView.MenuAction, - menuButton button: UIButton + viewModel: UserView.ViewModel, + 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: - // TODO: move to view controller Task { - let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) - guard let item = await item(from: source) else { + guard let user = viewModel.user?.asRecord else { assertionFailure() return } - guard case let .user(user) = item else { - assertionFailure("only works for user data") - return - } try await DataSourceFacade.responseToUserSignOut( - dependency: self, + 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 extension UserViewTableViewCellDelegate where Self: DataSourceProvider { - func tableViewCell( - _ cell: UITableViewCell, - userView: UserView, - friendshipButtonDidPressed button: UIButton - ) { - assertionFailure("TODO") - } +// func tableViewCell( +// _ cell: UITableViewCell, +// userView: UserView, +// friendshipButtonDidPressed button: UIButton +// ) { +// assertionFailure("TODO") +// } } // MARK: - membership -extension UserViewTableViewCellDelegate where Self: DataSourceProvider { - +extension UserViewTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider { func tableViewCell( _ cell: UITableViewCell, - userView: UserView, - membershipButtonDidPressed button: UIButton + viewModel: UserView.ViewModel, + listMembershipButtonDidPressed user: UserRecord ) { - guard !userView.viewModel.isListMemberCandidate else { + guard !viewModel.isListMemberCandidate else { return } - + Task { @MainActor in - guard let authenticationContext = context.authenticationService.activeAuthenticationContext else { - return - } - - 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 - } - + 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 userView.viewModel.isListMember { + if viewModel.isListMember { try await listMembershipViewModel.remove(user: user, authenticationContext: authenticationContext) } else { try await listMembershipViewModel.add(user: user, authenticationContext: authenticationContext) @@ -101,74 +132,89 @@ extension UserViewTableViewCellDelegate where Self: DataSourceProvider { config.interactiveHide = true let bannerView = NotificationBannerView() bannerView.configure(style: .warning) - bannerView.titleLabel.text = L10n.Common.Alerts.FailedToAddListMember.title + 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 -extension UserViewTableViewCellDelegate where Self: DataSourceProvider { - - func tableViewCell( - _ cell: UITableViewCell, - userView: UserView, - acceptFollowReqeustButtonDidPressed button: UIButton - ) { - Task { - guard let authenticationContext = context.authenticationService.activeAuthenticationContext else { - return - } - - let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) - guard let item = await item(from: source) else { - assertionFailure() - return - } - guard case let .notification(notification) = item else { - assertionFailure("only works for notification data") - return - } - - try await DataSourceFacade.responseToUserFollowRequestAction( - dependency: self, - notification: notification, - query: .accept, - authenticationContext: authenticationContext - ) - } // end Task - } - +extension UserViewTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider { func tableViewCell( _ cell: UITableViewCell, - userView: UserView, - rejectFollowReqeustButtonDidPressed button: UIButton + viewModel: UserView.ViewModel, + followReqeustButtonDidPressed user: UserRecord, + accept: Bool ) { Task { - guard let authenticationContext = context.authenticationService.activeAuthenticationContext else { - return - } - - let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) - guard let item = await item(from: source) else { + guard let notification = viewModel.notification?.asRecord else { assertionFailure() return } - guard case let .notification(notification) = item else { - assertionFailure("only works for notification data") - return - } try await DataSourceFacade.responseToUserFollowRequestAction( dependency: self, notification: notification, - query: .reject, - authenticationContext: authenticationContext + query: accept ? .accept : .reject, + authenticationContext: self.authContext.authenticationContext ) } // end Task } +// func tableViewCell( +// _ cell: UITableViewCell, +// userView: UserView, +// acceptFollowReqeustButtonDidPressed button: UIButton +// ) { +// Task { +// 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 .notification(notification) = item else { +// assertionFailure("only works for notification data") +// return +// } +// +// try await DataSourceFacade.responseToUserFollowRequestAction( +// dependency: self, +// notification: notification, +// query: .accept, +// authenticationContext: authenticationContext +// ) +// } // end Task +// } +// +// func tableViewCell( +// _ cell: UITableViewCell, +// userView: UserView, +// rejectFollowReqeustButtonDidPressed button: UIButton +// ) { +// Task { +// 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 .notification(notification) = item else { +// assertionFailure("only works for notification data") +// return +// } +// +// try await DataSourceFacade.responseToUserFollowRequestAction( +// dependency: self, +// notification: notification, +// query: .reject, +// authenticationContext: authenticationContext +// ) +// } // end Task +// } + } diff --git a/TwidereX/Protocol/ScrollViewContainer.swift b/TwidereX/Protocol/ScrollViewContainer.swift index cf2b5a12..65a5c63a 100644 --- a/TwidereX/Protocol/ScrollViewContainer.swift +++ b/TwidereX/Protocol/ScrollViewContainer.swift @@ -10,14 +10,22 @@ import UIKit protocol ScrollViewContainer: UIViewController { var scrollView: UIScrollView { get } - func scrollToTop(animated: Bool) + func scrollToTop(animated: Bool, option: ScrollViewContainerOption) } extension ScrollViewContainer { - func scrollToTop(animated: Bool) { + func scrollToTop(animated: Bool, option: ScrollViewContainerOption = .init()) { scrollView.scrollRectToVisible( CGRect(origin: .zero, size: CGSize(width: 1, height: 1)), animated: animated ) } } + +struct ScrollViewContainerOption { + let tryRefreshWhenStayAtTop: Bool + + init(tryRefreshWhenStayAtTop: Bool = true) { + self.tryRefreshWhenStayAtTop = tryRefreshWhenStayAtTop + } +} diff --git a/TwidereX/Scene/Account/List/AccountListViewController.swift b/TwidereX/Scene/Account/List/AccountListViewController.swift index 9c60f84a..b0e30dc4 100644 --- a/TwidereX/Scene/Account/List/AccountListViewController.swift +++ b/TwidereX/Scene/Account/List/AccountListViewController.swift @@ -12,7 +12,6 @@ import AuthenticationServices import Combine import CoreDataStack import TwitterSDK -import TwidereCommon final class AccountListViewController: UIViewController, NeedsDependency { @@ -32,7 +31,6 @@ final class AccountListViewController: UIViewController, NeedsDependency { }() private(set) lazy var tableView: UITableView = { let tableView = UITableView() - tableView.register(AccountListTableViewCell.self, forCellReuseIdentifier: String(describing: AccountListTableViewCell.self)) tableView.rowHeight = UITableView.automaticDimension tableView.separatorStyle = .none return tableView @@ -118,7 +116,7 @@ extension AccountListViewController: UITableViewDelegate { do { let isActive = try await self.context.authenticationService.activeAuthenticationIndex(record: record) guard isActive else { return } - self.coordinator.setup() + self.coordinator.setup(authentication: record) } catch { // handle error assertionFailure(error.localizedDescription) @@ -127,6 +125,11 @@ extension AccountListViewController: UITableViewDelegate { } } +// MARK: - AuthContextProvider +extension AccountListViewController: AuthContextProvider { + var authContext: AuthContext { viewModel.authContext } +} + // MARK: - UserViewTableViewCellDelegate extension AccountListViewController: UserViewTableViewCellDelegate { } diff --git a/TwidereX/Scene/Account/List/AccountListViewModel+Diffable.swift b/TwidereX/Scene/Account/List/AccountListViewModel+Diffable.swift index b3b30e6d..c322e0da 100644 --- a/TwidereX/Scene/Account/List/AccountListViewModel+Diffable.swift +++ b/TwidereX/Scene/Account/List/AccountListViewModel+Diffable.swift @@ -10,6 +10,7 @@ import UIKit import Combine import CoreDataStack import AlamofireImage +import TwidereCore extension AccountListViewModel { @@ -20,12 +21,9 @@ extension AccountListViewModel { diffableDataSource = UserSection.diffableDataSource( tableView: tableView, context: context, + authContext: authContext, configuration: UserSection.Configuration( - userViewTableViewCellDelegate: userViewTableViewCellDelegate, - userViewConfigurationContext: .init( - listMembershipViewModel: nil, - authenticationContext: context.authenticationService.activeAuthenticationContext - ) + userViewTableViewCellDelegate: userViewTableViewCellDelegate ) ) @@ -33,18 +31,14 @@ extension AccountListViewModel { snapshot.appendSections([.main]) diffableDataSource?.apply(snapshot) - context.authenticationService.$authenticationIndexes + $items .receive(on: DispatchQueue.main) - .sink { [weak self] authenticationIndexes in + .sink { [weak self] items in guard let self = self else { return } guard let diffableDataSource = self.diffableDataSource else { return } - + var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) - let items = authenticationIndexes.map { authenticationIndex -> UserItem in - let record = ManagedObjectRecord(objectID: authenticationIndex.objectID) - return UserItem.authenticationIndex(record: record) - } snapshot.appendItems(items, toSection: .main) diffableDataSource.apply(snapshot) } diff --git a/TwidereX/Scene/Account/List/AccountListViewModel.swift b/TwidereX/Scene/Account/List/AccountListViewModel.swift index 6dacbccb..c5fbe9bb 100644 --- a/TwidereX/Scene/Account/List/AccountListViewModel.swift +++ b/TwidereX/Scene/Account/List/AccountListViewModel.swift @@ -9,7 +9,9 @@ import os.log import UIKit import Combine +import CoreData import CoreDataStack +import TwidereCore final class AccountListViewModel: NSObject { @@ -17,14 +19,43 @@ final class AccountListViewModel: NSObject { // input let context: AppContext - + let authContext: AuthContext + let authenticationIndexFetchedResultsController: NSFetchedResultsController + // output var diffableDataSource: UITableViewDiffableDataSource! - var items = CurrentValueSubject<[UserItem], Never>([]) + @Published var items: [UserItem] = [] - init(context: AppContext) { + init( + context: AppContext, + authContext: AuthContext + ) { self.context = context + self.authContext = authContext + self.authenticationIndexFetchedResultsController = { + let fetchRequest = AuthenticationIndex.sortedFetchRequest + fetchRequest.returnsObjectsAsFaults = false + fetchRequest.fetchBatchSize = 20 + let controller = NSFetchedResultsController( + fetchRequest: fetchRequest, + managedObjectContext: context.managedObjectContext, + sectionNameKeyPath: nil, + cacheName: nil + ) + return controller + }() super.init() + + authenticationIndexFetchedResultsController.delegate = self + do { + try authenticationIndexFetchedResultsController.performFetch() + let authenticationIndexes = authenticationIndexFetchedResultsController.fetchedObjects ?? [] + items = authenticationIndexes.map { authenticationIndex in + UserItem.authenticationIndex(record: authenticationIndex.asRecrod) + } + } catch { + assertionFailure(error.localizedDescription) + } } deinit { @@ -32,3 +63,24 @@ final class AccountListViewModel: NSObject { } } + +// MARK: - NSFetchedResultsControllerDelegate +extension AccountListViewModel: NSFetchedResultsControllerDelegate { + + public func controllerWillChangeContent(_ controller: NSFetchedResultsController) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + + public func controllerDidChangeContent(_ controller: NSFetchedResultsController) { + switch controller { + case authenticationIndexFetchedResultsController: + let authenticationIndexes = authenticationIndexFetchedResultsController.fetchedObjects ?? [] + items = authenticationIndexes.map { authenticationIndex in + UserItem.authenticationIndex(record: authenticationIndex.asRecrod) + } + default: + assertionFailure() + } + } + +} diff --git a/TwidereX/Scene/Account/List/View/AccountListTableViewCell+ViewModel.swift b/TwidereX/Scene/Account/List/View/AccountListTableViewCell+ViewModel.swift index 620b0eb6..2f338137 100644 --- a/TwidereX/Scene/Account/List/View/AccountListTableViewCell+ViewModel.swift +++ b/TwidereX/Scene/Account/List/View/AccountListTableViewCell+ViewModel.swift @@ -1,78 +1,78 @@ +//// +//// AccountListTableViewCell+ViewModel.swift +//// AccountListTableViewCell+ViewModel +//// +//// Created by Cirno MainasuK on 2021-8-26. +//// Copyright © 2021 Twidere. All rights reserved. +//// // -// AccountListTableViewCell+ViewModel.swift -// AccountListTableViewCell+ViewModel +//import UIKit +//import Combine +//import CoreDataStack +//import MastodonMeta // -// Created by Cirno MainasuK on 2021-8-26. -// Copyright © 2021 Twidere. All rights reserved. +//extension AccountListTableViewCell { +// func configure(authenticationIndex: AuthenticationIndex) { +// if let twitterUser = authenticationIndex.twitterAuthentication?.user { +// configure(twitterUser: twitterUser) +// } else if let mastodonUser = authenticationIndex.mastodonAuthentication?.user { +// configure(mastodonUser: mastodonUser) +// } else { +// assertionFailure() +// } +// } // - -import UIKit -import Combine -import CoreDataStack -import MastodonMeta - -extension AccountListTableViewCell { - func configure(authenticationIndex: AuthenticationIndex) { - if let twitterUser = authenticationIndex.twitterAuthentication?.user { - configure(twitterUser: twitterUser) - } else if let mastodonUser = authenticationIndex.mastodonAuthentication?.user { - configure(mastodonUser: mastodonUser) - } else { - assertionFailure() - } - } - - private func configure(twitterUser user: TwitterUser) { - // badge - userBriefInfoView.viewModel.platform = .twitter - // avatar - user.publisher(for: \.profileImageURL) - .map { _ in user.avatarImageURL() } - .assign(to: \.avatarImageURL, on: userBriefInfoView.viewModel) - .store(in: &disposeBag) - // name - user.publisher(for: \.name) - .map { PlaintextMetaContent(string: $0) } - .assign(to: \.headlineMetaContent, on: userBriefInfoView.viewModel) - .store(in: &disposeBag) - // username - user.publisher(for: \.username) - .map { "@" + $0 } - .map { $0 as String? } - .assign(to: \.subheadlineText, on: userBriefInfoView.viewModel) - .store(in: &disposeBag) - } - - private func configure(mastodonUser user: MastodonUser) { - // badge - userBriefInfoView.viewModel.platform = .mastodon - // avatar - user.publisher(for: \.avatar) - .map { avatar in avatar.flatMap { URL(string: $0) } } - .assign(to: \.avatarImageURL, on: userBriefInfoView.viewModel) - .store(in: &disposeBag) - // name - Publishers.CombineLatest( - user.publisher(for: \.displayName), - user.publisher(for: \.emojis) - ) - .map { _, emojis -> MetaContent? in - let content = MastodonContent(content: user.name, emojis: emojis.asDictionary) - do { - let metaContent = try MastodonMetaContent.convert(document: content) - return metaContent - } catch { - assertionFailure() - return PlaintextMetaContent(string: user.name) - } - } - .assign(to: \.headlineMetaContent, on: userBriefInfoView.viewModel) - .store(in: &disposeBag) - // username - user.publisher(for: \.acct) - .map { _ in user.acctWithDomain } - .map { $0 as String? } - .assign(to: \.subheadlineText, on: userBriefInfoView.viewModel) - .store(in: &disposeBag) - } -} +// private func configure(twitterUser user: TwitterUser) { +// // badge +// userBriefInfoView.viewModel.platform = .twitter +// // avatar +// user.publisher(for: \.profileImageURL) +// .map { _ in user.avatarImageURL() } +// .assign(to: \.avatarImageURL, on: userBriefInfoView.viewModel) +// .store(in: &disposeBag) +// // name +// user.publisher(for: \.name) +// .map { PlaintextMetaContent(string: $0) } +// .assign(to: \.headlineMetaContent, on: userBriefInfoView.viewModel) +// .store(in: &disposeBag) +// // username +// user.publisher(for: \.username) +// .map { "@" + $0 } +// .map { $0 as String? } +// .assign(to: \.subheadlineText, on: userBriefInfoView.viewModel) +// .store(in: &disposeBag) +// } +// +// private func configure(mastodonUser user: MastodonUser) { +// // badge +// userBriefInfoView.viewModel.platform = .mastodon +// // avatar +// user.publisher(for: \.avatar) +// .map { avatar in avatar.flatMap { URL(string: $0) } } +// .assign(to: \.avatarImageURL, on: userBriefInfoView.viewModel) +// .store(in: &disposeBag) +// // name +// Publishers.CombineLatest( +// user.publisher(for: \.displayName), +// user.publisher(for: \.emojis) +// ) +// .map { _, emojis -> MetaContent? in +// let content = MastodonContent(content: user.name, emojis: emojis.asDictionary) +// do { +// let metaContent = try MastodonMetaContent.convert(document: content) +// return metaContent +// } catch { +// assertionFailure() +// return PlaintextMetaContent(string: user.name) +// } +// } +// .assign(to: \.headlineMetaContent, on: userBriefInfoView.viewModel) +// .store(in: &disposeBag) +// // username +// user.publisher(for: \.acct) +// .map { _ in user.acctWithDomain } +// .map { $0 as String? } +// .assign(to: \.subheadlineText, on: userBriefInfoView.viewModel) +// .store(in: &disposeBag) +// } +//} diff --git a/TwidereX/Scene/Account/List/View/AccountListTableViewCell.swift b/TwidereX/Scene/Account/List/View/AccountListTableViewCell.swift index fbf39be2..b006a387 100644 --- a/TwidereX/Scene/Account/List/View/AccountListTableViewCell.swift +++ b/TwidereX/Scene/Account/List/View/AccountListTableViewCell.swift @@ -1,69 +1,69 @@ +//// +//// AccountListTableViewCell.swift +//// TwidereX +//// +//// Created by Cirno MainasuK on 2020/11/11. +//// Copyright © 2020 Twidere. All rights reserved. +//// // -// AccountListTableViewCell.swift -// TwidereX +//import os.log +//import UIKit +//import Combine +//import TwidereCore // -// Created by Cirno MainasuK on 2020/11/11. -// Copyright © 2020 Twidere. All rights reserved. +//final class AccountListTableViewCell: UITableViewCell { +// +// var disposeBag = Set() +// var observations = Set() +// +// let userBriefInfoView = UserBriefInfoView() +// +// let separatorLine = SeparatorLineView() +// +// override func prepareForReuse() { +// super.prepareForReuse() +// +// disposeBag.removeAll() +// observations.removeAll() +// +// userBriefInfoView.prepareForReuse() +// } // - -import os.log -import UIKit -import Combine -import TwidereUI - -final class AccountListTableViewCell: UITableViewCell { - - var disposeBag = Set() - var observations = Set() - - let userBriefInfoView = UserBriefInfoView() - - let separatorLine = SeparatorLineView() - - override func prepareForReuse() { - super.prepareForReuse() - - disposeBag.removeAll() - observations.removeAll() - - userBriefInfoView.prepareForReuse() - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - _init() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } - -} - -extension AccountListTableViewCell { - - private func _init() { - userBriefInfoView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(userBriefInfoView) - NSLayoutConstraint.activate([ - userBriefInfoView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20), - userBriefInfoView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), - contentView.readableContentGuide.trailingAnchor.constraint(equalTo: userBriefInfoView.trailingAnchor), - contentView.bottomAnchor.constraint(equalTo: userBriefInfoView.bottomAnchor, constant: 16).priority(.defaultHigh), - ]) - - userBriefInfoView.secondaryHeadlineLabel.isHidden = true - userBriefInfoView.followActionButton.isHidden = true - - separatorLine.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(separatorLine) - NSLayoutConstraint.activate([ - separatorLine.leadingAnchor.constraint(equalTo: userBriefInfoView.headlineLabel.leadingAnchor), - separatorLine.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), - separatorLine.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)), - ]) - } - -} +// override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { +// super.init(style: style, reuseIdentifier: reuseIdentifier) +// _init() +// } +// +// required init?(coder: NSCoder) { +// super.init(coder: coder) +// _init() +// } +// +//} +// +//extension AccountListTableViewCell { +// +// private func _init() { +// userBriefInfoView.translatesAutoresizingMaskIntoConstraints = false +// contentView.addSubview(userBriefInfoView) +// NSLayoutConstraint.activate([ +// userBriefInfoView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20), +// userBriefInfoView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), +// contentView.readableContentGuide.trailingAnchor.constraint(equalTo: userBriefInfoView.trailingAnchor), +// contentView.bottomAnchor.constraint(equalTo: userBriefInfoView.bottomAnchor, constant: 16).priority(.defaultHigh), +// ]) +// +// userBriefInfoView.secondaryHeadlineLabel.isHidden = true +// userBriefInfoView.followActionButton.isHidden = true +// +// separatorLine.translatesAutoresizingMaskIntoConstraints = false +// contentView.addSubview(separatorLine) +// NSLayoutConstraint.activate([ +// separatorLine.leadingAnchor.constraint(equalTo: userBriefInfoView.headlineLabel.leadingAnchor), +// separatorLine.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), +// separatorLine.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), +// separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)), +// ]) +// } +// +//} diff --git a/TwidereX/Scene/Account/TwitterAccountUnlock/TwitterAccountUnlockViewController.swift b/TwidereX/Scene/Account/TwitterAccountUnlock/TwitterAccountUnlockViewController.swift index c6eefcc4..3f70726a 100644 --- a/TwidereX/Scene/Account/TwitterAccountUnlock/TwitterAccountUnlockViewController.swift +++ b/TwidereX/Scene/Account/TwitterAccountUnlock/TwitterAccountUnlockViewController.swift @@ -8,6 +8,7 @@ import UIKit import WebKit +import TwidereCore final class TwitterAccountUnlockViewController: UIViewController, NeedsDependency { diff --git a/TwidereX/Scene/Compose/ComposeViewController.swift b/TwidereX/Scene/Compose/ComposeViewController.swift index 3cc379de..c2be3a7f 100644 --- a/TwidereX/Scene/Compose/ComposeViewController.swift +++ b/TwidereX/Scene/Compose/ComposeViewController.swift @@ -10,7 +10,6 @@ import os.log import UIKit import Combine import AVKit -import TwidereUI final class ComposeViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { @@ -73,9 +72,6 @@ extension ComposeViewController { .assign(to: \.isEnabled, on: sendBarButtonItem) .store(in: &disposeBag) - // bind author - viewModel.$author.assign(to: &composeContentViewModel.$author) - composeContentViewController.delegate = self } @@ -112,10 +108,14 @@ extension ComposeViewController: ComposeContentViewControllerDelegate { ) { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + guard let authContext = viewController.viewModel.authContext else { return } + let _item: MediaPreviewViewModel.Item? switch attachmentViewModel.output { case .image(let data, _): _item = UIImage(data: data).flatMap { .image(.init(image: $0)) } + case .gif(let data, _): + _item = UIImage(data: data).flatMap { .image(.init(image: $0)) } case .video(let url, _): let playerViewController = AVPlayerViewController() playerViewController.player = AVPlayer(url: url) @@ -134,6 +134,7 @@ extension ComposeViewController: ComposeContentViewControllerDelegate { let mediaPreviewViewModel = MediaPreviewViewModel( context: context, + authContext: authContext, item: item, transitionItem: { let item = MediaPreviewTransitionItem( @@ -150,7 +151,7 @@ extension ComposeViewController: ComposeContentViewControllerDelegate { coordinator.present( scene: .mediaPreview(viewModel: mediaPreviewViewModel), from: self, - transition: .custom(transitioningDelegate: mediaPreviewTransitionController) + transition: .custom(animated: true, transitioningDelegate: mediaPreviewTransitionController) ) } diff --git a/TwidereX/Scene/Compose/ComposeViewModel.swift b/TwidereX/Scene/Compose/ComposeViewModel.swift index 54bdc464..be5d7abd 100644 --- a/TwidereX/Scene/Compose/ComposeViewModel.swift +++ b/TwidereX/Scene/Compose/ComposeViewModel.swift @@ -9,6 +9,7 @@ import Foundation import Combine import TwidereCore +import TwidereLocalization final class ComposeViewModel { @@ -16,22 +17,14 @@ final class ComposeViewModel { // input let context: AppContext + @Published public var viewLayoutFrame = ViewLayoutFrame() // output - @Published var author: UserObject? @Published var title = L10n.Scene.Compose.Title.compose init(context: AppContext) { self.context = context // end init - - context.authenticationService.activeAuthenticationIndex - .receive(on: DispatchQueue.main) - .sink { [weak self] authenticationIndex in - guard let self = self else { return } - self.author = authenticationIndex?.user - } - .store(in: &disposeBag) } } diff --git a/TwidereX/Scene/History/HistoryViewController.swift b/TwidereX/Scene/History/HistoryViewController.swift new file mode 100644 index 00000000..5818286b --- /dev/null +++ b/TwidereX/Scene/History/HistoryViewController.swift @@ -0,0 +1,128 @@ +// +// HistoryViewController.swift +// TwidereX +// +// Created by MainasuK on 2022-7-29. +// Copyright © 2022 Twidere. All rights reserved. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack +import Tabman +import Pageboy +import TwidereCore + +final class HistoryViewController: TabmanViewController, NeedsDependency, DrawerSidebarTransitionHostViewController { + + let logger = Logger(subsystem: "HistoryViewController", category: "ViewController") + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + var viewModel: HistoryViewModel! + + private(set) var drawerSidebarTransitionController: DrawerSidebarTransitionController! + let avatarBarButtonItem = AvatarBarButtonItem() + + private(set) lazy var pageSegmentedControl = UISegmentedControl() + + let optionBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "ellipsis.circle")) + + override func pageboyViewController( + _ pageboyViewController: PageboyViewController, + didScrollToPageAt index: TabmanViewController.PageIndex, + direction: PageboyViewController.NavigationDirection, + animated: Bool + ) { + super.pageboyViewController( + pageboyViewController, + didScrollToPageAt: index, + direction: direction, + animated: animated + ) + + viewModel.currentPageIndex = index + } + +} + +extension HistoryViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + isScrollEnabled = false // inner pan gesture untouchable. workaround to prevent swipe conflict + drawerSidebarTransitionController = DrawerSidebarTransitionController(hostViewController: self) + + view.backgroundColor = .systemBackground + + setupSegmentedControl(scopes: viewModel.scopes) + navigationItem.titleView = pageSegmentedControl + pageSegmentedControl.addTarget(self, action: #selector(HistoryViewController.pageSegmentedControlValueChanged(_:)), for: .valueChanged) + + navigationItem.rightBarButtonItem = optionBarButtonItem + optionBarButtonItem.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: [ + UIAction(title: "Clear", image: UIImage(systemName: "minus.circle"), identifier: nil, discoverabilityTitle: nil, attributes: [.destructive], state: .off, handler: { [weak self] action in + guard let self = self else { return } + Task { + let managedObjectContext = self.context.backgroundManagedObjectContext + let acct = self.viewModel.authContext.authenticationContext.acct + try await managedObjectContext.performChanges { + let request = History.sortedFetchRequest + request.predicate = History.predicate(acct: acct) + let histories = try managedObjectContext.fetch(request) + for history in histories { + managedObjectContext.delete(history) + } + } + } // end Task + }) + ]) + + dataSource = viewModel + } + +} + +extension HistoryViewController { + private func setupSegmentedControl(scopes: [HistoryViewModel.Scope]) { + pageSegmentedControl.removeAllSegments() + for (i, scope) in scopes.enumerated() { + let title = scope.title(platform: viewModel.platform) + pageSegmentedControl.insertSegment(withTitle: title, at: i, animated: false) + } + + // set initial selection + guard !pageSegmentedControl.isSelected else { return } + if viewModel.currentPageIndex < pageSegmentedControl.numberOfSegments { + pageSegmentedControl.selectedSegmentIndex = viewModel.currentPageIndex + } else { + pageSegmentedControl.selectedSegmentIndex = 0 + } + + pageSegmentedControl.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + pageSegmentedControl.widthAnchor.constraint(greaterThanOrEqualToConstant: 240) + ]) + } +} + +extension HistoryViewController { + + @objc private func pageSegmentedControlValueChanged(_ sender: UISegmentedControl) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + + let index = sender.selectedSegmentIndex + scrollToPage(.at(index: index), animated: true, completion: nil) + } + +} + +// MARK: - AuthContextProvider +extension HistoryViewController: AuthContextProvider { + var authContext: AuthContext { viewModel.authContext } +} diff --git a/TwidereX/Scene/History/HistoryViewModel.swift b/TwidereX/Scene/History/HistoryViewModel.swift new file mode 100644 index 00000000..ff97eeb5 --- /dev/null +++ b/TwidereX/Scene/History/HistoryViewModel.swift @@ -0,0 +1,99 @@ +// +// HistoryViewModel.swift +// TwidereX +// +// Created by MainasuK on 2022-7-29. +// Copyright © 2022 Twidere. All rights reserved. +// + +import UIKit +import Combine +import Pageboy +import TwidereCore +import CoreDataStack + +final class HistoryViewModel { + + var disposeBag = Set() + + // input + let context: AppContext + let _coordinator: SceneCoordinator // only use for `setup` + let authContext: AuthContext + + // output + let platform: Platform + let scopes = Scope.allCases + let viewControllers: [UIViewController] + @Published var currentPageIndex = 0 + + init( + context: AppContext, + coordinator: SceneCoordinator, + authContext: AuthContext + ) { + self.context = context + self._coordinator = coordinator + self.authContext = authContext + self.platform = { + switch authContext.authenticationContext { + case .twitter: return .twitter + case .mastodon: return .mastodon + } + }() + self.viewControllers = { + // status + let statusHistoryViewController = StatusHistoryViewController() + statusHistoryViewController.context = context + statusHistoryViewController.coordinator = coordinator + statusHistoryViewController.viewModel = StatusHistoryViewModel(context: context, authContext: authContext) + // user + let userHistoryViewController = UserHistoryViewController() + userHistoryViewController.context = context + userHistoryViewController.coordinator = coordinator + userHistoryViewController.viewModel = UserHistoryViewModel(context: context, authContext: authContext) + return [statusHistoryViewController, userHistoryViewController] + }() + // end init + } + +} + +extension HistoryViewModel { + enum Scope: Hashable, CaseIterable { + case status + case user + + func title(platform: Platform) -> String { + switch self { + case .status: + switch platform { + case .twitter: return L10n.Scene.History.Scope.tweet + case .mastodon: return L10n.Scene.History.Scope.toot + case .none: + assertionFailure() + return "Post" + } + case .user: + return L10n.Scene.History.Scope.user + } + } + } +} + +// MARK: - PageboyViewControllerDataSource +extension HistoryViewModel: PageboyViewControllerDataSource { + + func numberOfViewControllers(in pageboyViewController: PageboyViewController) -> Int { + return viewControllers.count + } + + func viewController(for pageboyViewController: PageboyViewController, at index: PageboyViewController.PageIndex) -> UIViewController? { + return viewControllers[index] + } + + func defaultPage(for pageboyViewController: PageboyViewController) -> PageboyViewController.Page? { + return .first + } + +} diff --git a/TwidereX/Scene/History/Status/StatusHistoryViewController+DataSourceProvider.swift b/TwidereX/Scene/History/Status/StatusHistoryViewController+DataSourceProvider.swift new file mode 100644 index 00000000..c90e3355 --- /dev/null +++ b/TwidereX/Scene/History/Status/StatusHistoryViewController+DataSourceProvider.swift @@ -0,0 +1,41 @@ +// +// StatusHistoryViewController+DataSourceProvider.swift +// TwidereX +// +// Created by MainasuK on 2022-7-29. +// Copyright © 2022 Twidere. All rights reserved. +// + +import UIKit +import TwidereCore + +extension StatusHistoryViewController: DataSourceProvider { + func item(from source: DataSourceItem.Source) async -> DataSourceItem? { + var _indexPath = source.indexPath + if _indexPath == nil, let cell = source.tableViewCell { + _indexPath = await self.indexPath(for: cell) + } + guard let indexPath = _indexPath else { return nil } + + guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { + return nil + } + + switch item { + case .history(let record): + let managedObjectContext = context.managedObjectContext + let item: DataSourceItem? = await managedObjectContext.perform { + guard let history = record.object(in: managedObjectContext) else { return nil } + guard let status = history.statusObject else { return nil } + return .status(status.asRecord) + + } + return item + } + } + + @MainActor + private func indexPath(for cell: UITableViewCell) async -> IndexPath? { + return tableView.indexPath(for: cell) + } +} diff --git a/TwidereX/Scene/History/Status/StatusHistoryViewController.swift b/TwidereX/Scene/History/Status/StatusHistoryViewController.swift new file mode 100644 index 00000000..650f4f81 --- /dev/null +++ b/TwidereX/Scene/History/Status/StatusHistoryViewController.swift @@ -0,0 +1,154 @@ +// +// StatusHistoryViewController.swift +// TwidereX +// +// Created by MainasuK on 2022-7-29. +// Copyright © 2022 Twidere. All rights reserved. +// + +import os.log +import UIKit +import Combine +import CoreDataStack + +final class StatusHistoryViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { + + let logger = Logger(subsystem: "StatusHistoryViewController", category: "ViewController") + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + var viewModel: StatusHistoryViewModel! + + let mediaPreviewTransitionController = MediaPreviewTransitionController() + + private(set) lazy var tableView: UITableView = { + let tableView = UITableView() + tableView.backgroundColor = .systemBackground + tableView.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + tableView.sectionHeaderTopPadding = .zero + return tableView + }() + +} + +extension StatusHistoryViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .systemBackground + + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + tableView.delegate = self + viewModel.setupDiffableDataSource( + tableView: tableView, + statusViewTableViewCellDelegate: self + ) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + tableView.deselectRow(with: transitionCoordinator, animated: animated) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + viewModel.viewLayoutFrame.update(view: view) + } + + override func viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + + viewModel.viewLayoutFrame.update(view: view) + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + coordinator.animate {[weak self] _ in + guard let self = self else { return } + self.viewModel.viewLayoutFrame.update(view: self.view) + } completion: { _ in + // do nothing + } + } + +} + +// MARK: - UITableViewDelegate +extension StatusHistoryViewController: UITableViewDelegate, AutoGenerateTableViewDelegate { + // sourcery:inline:StatusHistoryViewController.AutoGenerateTableViewDelegate + + // Generated using Sourcery + // DO NOT EDIT + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + aspectTableView(tableView, didSelectRowAt: indexPath) + } + + func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point) + } + + func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator) + } + // sourcery:end + + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + guard let diffableDataSource = viewModel.diffableDataSource else { return nil } + guard let sectionIdentifier = diffableDataSource.sectionIdentifier(for: section) else { return nil } + switch sectionIdentifier { + case .group(let identifer): + guard let date = History.date(from: identifer) else { return nil } + let formatter = DateFormatter() + formatter.dateStyle = .long + formatter.timeStyle = .none + + let title = formatter.string(from: date) + let header = TableViewSectionTextHeaderView() + header.label.text = title + + let container = UIView() + container.backgroundColor = .secondarySystemBackground + header.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(header) + NSLayoutConstraint.activate([ + header.topAnchor.constraint(equalTo: container.topAnchor), + header.leadingAnchor.constraint(equalTo: container.readableContentGuide.leadingAnchor), + header.trailingAnchor.constraint(equalTo: container.readableContentGuide.trailingAnchor), + header.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + return container + } + } + +} + +// MARK: - AuthContextProvider +extension StatusHistoryViewController: AuthContextProvider { + var authContext: AuthContext { viewModel.authContext } +} + +// MARK: - StatusViewTableViewCellDelegate +extension StatusHistoryViewController: StatusViewTableViewCellDelegate { } diff --git a/TwidereX/Scene/History/Status/StatusHistoryViewModel+Diffable.swift b/TwidereX/Scene/History/Status/StatusHistoryViewModel+Diffable.swift new file mode 100644 index 00000000..777c2d03 --- /dev/null +++ b/TwidereX/Scene/History/Status/StatusHistoryViewModel+Diffable.swift @@ -0,0 +1,52 @@ +// +// StatusHistoryViewModel+Diffable.swift +// TwidereX +// +// Created by MainasuK on 2022-7-29. +// Copyright © 2022 Twidere. All rights reserved. +// + +import UIKit +import Combine + +extension StatusHistoryViewModel { + + func setupDiffableDataSource( + tableView: UITableView, + statusViewTableViewCellDelegate: StatusViewTableViewCellDelegate + ) { + diffableDataSource = HistorySection.diffableDataSource( + tableView: tableView, + context: context, + authContext: authContext, + configuration: .init( + statusViewTableViewCellDelegate: statusViewTableViewCellDelegate, + userViewTableViewCellDelegate: nil, + viewLayoutFramePublisher: $viewLayoutFrame + ) + ) + + let snapshot = NSDiffableDataSourceSnapshot() + diffableDataSource?.apply(snapshot) + + historyFetchedResultsController.$groupedRecords + .receive(on: DispatchQueue.main) + .sink { [weak self] groupedRecords in + guard let self = self else { return } + guard let diffableDataSource = self.diffableDataSource else { return } + + var snapshot = NSDiffableDataSourceSnapshot() + for (identifier, records) in groupedRecords { + let section = HistorySection.group(identifer: identifier) + snapshot.appendSections([section]) + let items: [HistoryItem] = records.map { .history(record: $0) } + snapshot.appendItems(items, toSection: section) + } + + diffableDataSource.apply(snapshot, animatingDifferences: false) + } + .store(in: &disposeBag) + } + +} + diff --git a/TwidereX/Scene/History/Status/StatusHistoryViewModel.swift b/TwidereX/Scene/History/Status/StatusHistoryViewModel.swift new file mode 100644 index 00000000..999b1493 --- /dev/null +++ b/TwidereX/Scene/History/Status/StatusHistoryViewModel.swift @@ -0,0 +1,48 @@ +// +// StatusHistoryViewModel.swift +// TwidereX +// +// Created by MainasuK on 2022-7-29. +// Copyright © 2022 Twidere. All rights reserved. +// + +import os.log +import UIKit +import Combine +import CoreDataStack +import MastodonSDK +import TwidereCore + +final class StatusHistoryViewModel { + + let logger = Logger(subsystem: "StatusHistoryViewModel", category: "ViewModel") + + var disposeBag = Set() + + // input + let context: AppContext + let authContext: AuthContext + let historyFetchedResultsController: HistoryFetchedResultsController + + @Published public var viewLayoutFrame = ViewLayoutFrame() + + // output + var diffableDataSource: UITableViewDiffableDataSource? + + init( + context: AppContext, + authContext: AuthContext + ) { + self.context = context + self.authContext = authContext + self.historyFetchedResultsController = HistoryFetchedResultsController(managedObjectContext: context.managedObjectContext) + // end init + + historyFetchedResultsController.predicate = History.statusPredicate(acct: authContext.authenticationContext.acct) + } + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) + } + +} diff --git a/TwidereX/Scene/History/User/UserHistoryViewController+DataSourceProvider.swift b/TwidereX/Scene/History/User/UserHistoryViewController+DataSourceProvider.swift new file mode 100644 index 00000000..510833cf --- /dev/null +++ b/TwidereX/Scene/History/User/UserHistoryViewController+DataSourceProvider.swift @@ -0,0 +1,40 @@ +// +// UserHistoryViewController+DataSourceProvider.swift +// TwidereX +// +// Created by MainasuK on 2022-8-1. +// Copyright © 2022 Twidere. All rights reserved. +// + +import UIKit + +extension UserHistoryViewController: DataSourceProvider { + func item(from source: DataSourceItem.Source) async -> DataSourceItem? { + var _indexPath = source.indexPath + if _indexPath == nil, let cell = source.tableViewCell { + _indexPath = await self.indexPath(for: cell) + } + guard let indexPath = _indexPath else { return nil } + + guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { + return nil + } + + switch item { + case .history(let record): + let managedObjectContext = context.managedObjectContext + let item: DataSourceItem? = await managedObjectContext.perform { + guard let history = record.object(in: managedObjectContext) else { return nil } + guard let user = history.userObject else { return nil } + return .user(user.asRecord) + + } + return item + } + } + + @MainActor + private func indexPath(for cell: UITableViewCell) async -> IndexPath? { + return tableView.indexPath(for: cell) + } +} diff --git a/TwidereX/Scene/History/User/UserHistoryViewController.swift b/TwidereX/Scene/History/User/UserHistoryViewController.swift new file mode 100644 index 00000000..0e929391 --- /dev/null +++ b/TwidereX/Scene/History/User/UserHistoryViewController.swift @@ -0,0 +1,155 @@ +// +// UserHistoryViewController.swift +// TwidereX +// +// Created by MainasuK on 2022-7-29. +// Copyright © 2022 Twidere. All rights reserved. +// + +import os.log +import UIKit +import Combine +import CoreDataStack + +final class UserHistoryViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { + + let logger = Logger(subsystem: "UserHistoryViewController", category: "ViewController") + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + var viewModel: UserHistoryViewModel! + + let mediaPreviewTransitionController = MediaPreviewTransitionController() + + private(set) lazy var tableView: UITableView = { + let tableView = UITableView() + tableView.backgroundColor = .systemBackground + tableView.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + tableView.sectionHeaderTopPadding = .zero + return tableView + }() + +} + +extension UserHistoryViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .systemBackground + + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + tableView.delegate = self + viewModel.setupDiffableDataSource( + tableView: tableView, + userViewTableViewCellDelegate: self + ) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + tableView.deselectRow(with: transitionCoordinator, animated: animated) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + viewModel.viewLayoutFrame.update(view: view) + } + + override func viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + + viewModel.viewLayoutFrame.update(view: view) + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + coordinator.animate {[weak self] _ in + guard let self = self else { return } + self.viewModel.viewLayoutFrame.update(view: self.view) + } completion: { _ in + // do nothing + } + } + +} + +// MARK: - AuthContextProvider +extension UserHistoryViewController: AuthContextProvider { + var authContext: AuthContext { viewModel.authContext } +} + +// MARK: - UITableViewDelegate +extension UserHistoryViewController: UITableViewDelegate, AutoGenerateTableViewDelegate { + // sourcery:inline:UserHistoryViewController.AutoGenerateTableViewDelegate + + // Generated using Sourcery + // DO NOT EDIT + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + aspectTableView(tableView, didSelectRowAt: indexPath) + } + + func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point) + } + + func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator) + } + + // sourcery:end + + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + guard let diffableDataSource = viewModel.diffableDataSource else { return nil } + guard let sectionIdentifier = diffableDataSource.sectionIdentifier(for: section) else { return nil } + switch sectionIdentifier { + case .group(let identifer): + guard let date = History.date(from: identifer) else { return nil } + let formatter = DateFormatter() + formatter.dateStyle = .long + formatter.timeStyle = .none + + let title = formatter.string(from: date) + let header = TableViewSectionTextHeaderView() + header.label.text = title + + let container = UIView() + container.backgroundColor = .secondarySystemBackground + header.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(header) + NSLayoutConstraint.activate([ + header.topAnchor.constraint(equalTo: container.topAnchor), + header.leadingAnchor.constraint(equalTo: container.readableContentGuide.leadingAnchor), + header.trailingAnchor.constraint(equalTo: container.readableContentGuide.trailingAnchor), + header.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + return container + } + } + +} + +// MARK: - UserViewTableViewCellDelegate +extension UserHistoryViewController: UserViewTableViewCellDelegate { } diff --git a/TwidereX/Scene/History/User/UserHistoryViewModel+Diffable.swift b/TwidereX/Scene/History/User/UserHistoryViewModel+Diffable.swift new file mode 100644 index 00000000..0f44b70c --- /dev/null +++ b/TwidereX/Scene/History/User/UserHistoryViewModel+Diffable.swift @@ -0,0 +1,51 @@ +// +// UserHistoryViewModel+Diffable.swift +// TwidereX +// +// Created by MainasuK on 2022-8-1. +// Copyright © 2022 Twidere. All rights reserved. +// + +import UIKit +import Combine + +extension UserHistoryViewModel { + + func setupDiffableDataSource( + tableView: UITableView, + userViewTableViewCellDelegate: UserViewTableViewCellDelegate + ) { + diffableDataSource = HistorySection.diffableDataSource( + tableView: tableView, + context: context, + authContext: authContext, + configuration: .init( + statusViewTableViewCellDelegate: nil, + userViewTableViewCellDelegate: userViewTableViewCellDelegate, + viewLayoutFramePublisher: $viewLayoutFrame + ) + ) + + let snapshot = NSDiffableDataSourceSnapshot() + diffableDataSource?.apply(snapshot) + + historyFetchedResultsController.$groupedRecords + .receive(on: DispatchQueue.main) + .sink { [weak self] groupedRecords in + guard let self = self else { return } + guard let diffableDataSource = self.diffableDataSource else { return } + + var snapshot = NSDiffableDataSourceSnapshot() + for (identifier, records) in groupedRecords { + let section = HistorySection.group(identifer: identifier) + snapshot.appendSections([section]) + let items: [HistoryItem] = records.map { .history(record: $0) } + snapshot.appendItems(items, toSection: section) + } + + diffableDataSource.apply(snapshot, animatingDifferences: false) + } + .store(in: &disposeBag) + } + +} diff --git a/TwidereX/Scene/History/User/UserHistoryViewModel.swift b/TwidereX/Scene/History/User/UserHistoryViewModel.swift new file mode 100644 index 00000000..ee6f5ffc --- /dev/null +++ b/TwidereX/Scene/History/User/UserHistoryViewModel.swift @@ -0,0 +1,48 @@ +// +// UserHistoryViewModel.swift +// TwidereX +// +// Created by MainasuK on 2022-7-29. +// Copyright © 2022 Twidere. All rights reserved. +// + +import os.log +import UIKit +import Combine +import CoreDataStack +import MastodonSDK +import TwidereCore + +final class UserHistoryViewModel { + + let logger = Logger(subsystem: "UserHistoryViewModel", category: "ViewModel") + + var disposeBag = Set() + + // input + let context: AppContext + let authContext: AuthContext + let historyFetchedResultsController: HistoryFetchedResultsController + + @Published public var viewLayoutFrame = ViewLayoutFrame() + + // output + var diffableDataSource: UITableViewDiffableDataSource? + + init( + context: AppContext, + authContext: AuthContext + ) { + self.context = context + self.authContext = authContext + self.historyFetchedResultsController = HistoryFetchedResultsController(managedObjectContext: context.managedObjectContext) + // end init + + historyFetchedResultsController.predicate = History.userPredicate(acct: authContext.authenticationContext.acct) + } + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) + } + +} diff --git a/TwidereX/Scene/List/AddListMember/AddListMemberViewController.swift b/TwidereX/Scene/List/AddListMember/AddListMemberViewController.swift index ff0d1986..48c42d06 100644 --- a/TwidereX/Scene/List/AddListMember/AddListMemberViewController.swift +++ b/TwidereX/Scene/List/AddListMember/AddListMemberViewController.swift @@ -51,6 +51,7 @@ extension AddListMemberViewController { searchUserViewController.coordinator = coordinator searchUserViewController.viewModel = SearchUserViewModel( context: context, + authContext: viewModel.authContext, kind: .listMember(list: viewModel.list) ) viewModel.$userIdentifier.assign(to: &searchUserViewController.viewModel.$userIdentifier) diff --git a/TwidereX/Scene/List/AddListMember/AddListMemberViewModel.swift b/TwidereX/Scene/List/AddListMember/AddListMemberViewModel.swift index 01cf99c9..85fd920d 100644 --- a/TwidereX/Scene/List/AddListMember/AddListMemberViewModel.swift +++ b/TwidereX/Scene/List/AddListMember/AddListMemberViewModel.swift @@ -13,6 +13,7 @@ final class AddListMemberViewModel { // input let context: AppContext + let authContext: AuthContext let list: ListRecord weak var listMembershipViewModelDelegate: ListMembershipViewModelDelegate? @@ -21,15 +22,15 @@ final class AddListMemberViewModel { init( context: AppContext, + authContext: AuthContext, list: ListRecord ) { self.context = context + self.authContext = authContext self.list = list // end init - context.authenticationService.$activeAuthenticationContext - .map { $0?.userIdentifier } - .assign(to: &$userIdentifier) + userIdentifier = authContext.authenticationContext.userIdentifier } } diff --git a/TwidereX/Scene/List/CompositeList/CompositeListViewController.swift b/TwidereX/Scene/List/CompositeList/CompositeListViewController.swift index d28f950e..64a74dc0 100644 --- a/TwidereX/Scene/List/CompositeList/CompositeListViewController.swift +++ b/TwidereX/Scene/List/CompositeList/CompositeListViewController.swift @@ -10,7 +10,6 @@ import os.log import UIKit import Combine import TwidereLocalization -import TwidereUI import TwidereCore class CompositeListViewController: UIViewController, NeedsDependency { @@ -99,6 +98,7 @@ extension CompositeListViewController { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") let editListViewModel = EditListViewModel( context: context, + authContext: viewModel.authContext, platform: { switch viewModel.kind.user { case .twitter: return .twitter @@ -159,7 +159,7 @@ extension CompositeListViewController: UITableViewDelegate { Task { switch item { case .list(let record, _): - let listStatusViewModel = ListStatusTimelineViewModel(context: context, list: record) + let listStatusViewModel = ListStatusTimelineViewModel(context: context, authContext: viewModel.authContext, list: record) coordinator.present( scene: .listStatus(viewModel: listStatusViewModel), from: self, diff --git a/TwidereX/Scene/List/CompositeList/CompositeListViewModel+Diffable.swift b/TwidereX/Scene/List/CompositeList/CompositeListViewModel+Diffable.swift index ee4cab03..438bcc9a 100644 --- a/TwidereX/Scene/List/CompositeList/CompositeListViewModel+Diffable.swift +++ b/TwidereX/Scene/List/CompositeList/CompositeListViewModel+Diffable.swift @@ -64,7 +64,9 @@ extension CompositeListViewModel { let _subscribedListSection: ListSection? = { switch user { - case .twitter: return ListSection.twitter(kind: .subscribed) + // Deprecated: hide due to API invalid + // case .twitter: return ListSection.twitter(kind: .subscribed) + case .twitter: return nil case .mastodon: return nil } }() diff --git a/TwidereX/Scene/List/CompositeList/CompositeListViewModel.swift b/TwidereX/Scene/List/CompositeList/CompositeListViewModel.swift index 779c4687..59082b3b 100644 --- a/TwidereX/Scene/List/CompositeList/CompositeListViewModel.swift +++ b/TwidereX/Scene/List/CompositeList/CompositeListViewModel.swift @@ -17,6 +17,7 @@ class CompositeListViewModel { // input let context: AppContext + let authContext: AuthContext let kind: Kind let listBatchFetchViewModel = ListBatchFetchViewModel() @@ -29,19 +30,21 @@ class CompositeListViewModel { init( context: AppContext, + authContext: AuthContext, kind: Kind ) { self.context = context + self.authContext = authContext self.kind = kind switch kind { case .lists: - self.ownedListViewModel = ListViewModel(context: context, kind: .owned(user: kind.user)) - self.subscribedListViewModel = ListViewModel(context: context, kind: .subscribed(user: kind.user)) - self.listedListViewModel = ListViewModel(context: context, kind: .none) + self.ownedListViewModel = ListViewModel(context: context, authContext: authContext, kind: .owned(user: kind.user)) + self.subscribedListViewModel = ListViewModel(context: context, authContext: authContext, kind: .subscribed(user: kind.user)) + self.listedListViewModel = ListViewModel(context: context, authContext: authContext, kind: .none) case .listed: - self.ownedListViewModel = ListViewModel(context: context, kind: .none) - self.subscribedListViewModel = ListViewModel(context: context, kind: .none) - self.listedListViewModel = ListViewModel(context: context, kind: .listed(user: kind.user)) + self.ownedListViewModel = ListViewModel(context: context, authContext: authContext, kind: .none) + self.subscribedListViewModel = ListViewModel(context: context, authContext: authContext, kind: .none) + self.listedListViewModel = ListViewModel(context: context, authContext: authContext, kind: .listed(user: kind.user)) } // end init } diff --git a/TwidereX/Scene/List/EditList/EditListView.swift b/TwidereX/Scene/List/EditList/EditListView.swift index 6683f89d..ef56e7ae 100644 --- a/TwidereX/Scene/List/EditList/EditListView.swift +++ b/TwidereX/Scene/List/EditList/EditListView.swift @@ -8,6 +8,7 @@ import SwiftUI import TwidereLocalization +import TwidereCore struct EditListView: View { @@ -40,12 +41,14 @@ struct EditListView: View { struct CreateListView_Previews: PreviewProvider { static var previews: some View { Group { - EditListView(viewModel: EditListViewModel(context: .shared, platform: .twitter, kind: .create)) - EditListView(viewModel: EditListViewModel(context: .shared, platform: .twitter, kind: .create)) - .preferredColorScheme(.dark) - EditListView(viewModel: EditListViewModel(context: .shared, platform: .mastodon, kind: .create)) - EditListView(viewModel: EditListViewModel(context: .shared, platform: .mastodon, kind: .create)) - .preferredColorScheme(.dark) + if let authContext = AuthContext.mock(context: .shared) { + EditListView(viewModel: EditListViewModel(context: .shared, authContext: authContext, platform: .twitter, kind: .create)) + EditListView(viewModel: EditListViewModel(context: .shared, authContext: authContext, platform: .twitter, kind: .create)) + .preferredColorScheme(.dark) + EditListView(viewModel: EditListViewModel(context: .shared, authContext: authContext, platform: .mastodon, kind: .create)) + EditListView(viewModel: EditListViewModel(context: .shared, authContext: authContext, platform: .mastodon, kind: .create)) + .preferredColorScheme(.dark) + } } } } diff --git a/TwidereX/Scene/List/EditList/EditListViewModel.swift b/TwidereX/Scene/List/EditList/EditListViewModel.swift index d0dee900..d122c18c 100644 --- a/TwidereX/Scene/List/EditList/EditListViewModel.swift +++ b/TwidereX/Scene/List/EditList/EditListViewModel.swift @@ -21,6 +21,7 @@ final class EditListViewModel: ObservableObject { // input let context: AppContext + let authContext: AuthContext let platform: Platform let kind: Kind @@ -34,10 +35,12 @@ final class EditListViewModel: ObservableObject { init( context: AppContext, + authContext: AuthContext, platform: Platform, kind: Kind ) { self.context = context + self.authContext = authContext self.platform = platform self.kind = kind // end init @@ -92,9 +95,7 @@ extension EditListViewModel { )) } }() - guard let query = _query, - let authenticationContext = context.authenticationService.activeAuthenticationContext - else { + guard let query = _query else { throw AppError.implicit(.badRequest) } @@ -107,7 +108,7 @@ extension EditListViewModel { do { let response = try await context.apiService.create( query: query, - authenticationContext: authenticationContext + authenticationContext: authContext.authenticationContext ) logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): create list success") @@ -184,9 +185,7 @@ extension EditListViewModel { )) } }() - guard let query = _query, - let authenticationContext = context.authenticationService.activeAuthenticationContext - else { + guard let query = _query else { throw AppError.implicit(.badRequest) } @@ -200,7 +199,7 @@ extension EditListViewModel { let response = try await context.apiService.update( list: list, query: query, - authenticationContext: authenticationContext + authenticationContext: authContext.authenticationContext ) logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): update list success") diff --git a/TwidereX/Scene/List/HomeList/HomeListStatusTimelineViewController.swift b/TwidereX/Scene/List/HomeList/HomeListStatusTimelineViewController.swift new file mode 100644 index 00000000..6d6a53ec --- /dev/null +++ b/TwidereX/Scene/List/HomeList/HomeListStatusTimelineViewController.swift @@ -0,0 +1,288 @@ +// +// HomeListStatusTimelineViewController.swift +// TwidereX +// +// Created by MainasuK on 2023/4/26. +// Copyright © 2023 Twidere. All rights reserved. +// + +import os.log +import UIKit +import SwiftUI +import Combine +import TwidereLocalization + +final class HomeListStatusTimelineViewController: UIViewController, NeedsDependency, DrawerSidebarTransitionHostViewController, MediaPreviewableViewController { + + let logger = Logger(subsystem: "HomeListStatusTimelineViewController", category: "ViewController") + + // MARK: NeedsDependency + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + // MARK: DrawerSidebarTransitionHostViewController + private(set) var drawerSidebarTransitionController: DrawerSidebarTransitionController! + let avatarBarButtonItem = AvatarBarButtonItem() + + // MARK: MediaPreviewTransitionHostViewController + let mediaPreviewTransitionController = MediaPreviewTransitionController() + + public var viewModel: HomeListStatusTimelineViewModel! + var disposeBag = Set() + + let emptyStateViewModel = EmptyStateView.ViewModel() + + @Published var listStatusTimelineViewController: ListTimelineViewController? +} + +extension HomeListStatusTimelineViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + drawerSidebarTransitionController = DrawerSidebarTransitionController(hostViewController: self) + + view.backgroundColor = .systemBackground + + // setup avatarBarButtonItem + if navigationController?.viewControllers.first == self { + coordinator.$needsSetupAvatarBarButtonItem + .receive(on: DispatchQueue.main) + .sink { [weak self] needsSetupAvatarBarButtonItem in + guard let self = self else { return } + if let leftBarButtonItem = self.navigationItem.leftBarButtonItem, + leftBarButtonItem !== self.avatarBarButtonItem + { + // allow override + return + } + self.navigationItem.leftBarButtonItem = needsSetupAvatarBarButtonItem ? self.avatarBarButtonItem : nil + } + .store(in: &disposeBag) + } + avatarBarButtonItem.avatarButton.addTarget(self, action: #selector(HomeListStatusTimelineViewController.avatarButtonPressed(_:)), for: .touchUpInside) + avatarBarButtonItem.delegate = self + + viewModel.delegate = self + viewModel.viewDidAppear + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + let user = self.viewModel.authContext.authenticationContext.user(in: self.context.managedObjectContext) + self.avatarBarButtonItem.configure(user: user) + } + .store(in: &disposeBag) + + navigationItem.titleMenuProvider = { [weak self] _ -> UIMenu? in + guard let self = self else { return nil } + + defer { + self.reloadList() + } + + let menuContext = self.viewModel.createHomeListMenuContext() + + var children: [UIMenuElement] = [ + menuContext.homeTimelineMenu, + menuContext.ownedListMenu, + menuContext.subscribedListMenu, + ] + + if menuContext.isEmpty { + let deferredMenuElement = UIDeferredMenuElement.uncached { handler in + Task { + let manageListAction = UIAction(title: "Manage List", image: UIImage(systemName: "list.bullet")) { [weak self] _ in + guard let self = self else { return } + guard let me = self.authContext.authenticationContext.user(in: self.context.managedObjectContext)?.asRecord else { return } + let compositeListViewModel = CompositeListViewModel( + context: self.context, + authContext: self.authContext, + kind: .lists(me) + ) + self.coordinator.present(scene: .compositeList(viewModel: compositeListViewModel), from: self, transition: .show) + } + handler([manageListAction]) + } // end Task + } + children.append(deferredMenuElement) + } + + // root menu + return UIMenu(children: children) + } + + viewModel.$homeListMenuContext + .sink { [weak self] menuContext in + guard let self = self else { return } + self.attachTimelineViewController(menuContext: menuContext) + } + .store(in: &disposeBag) + + let emptyStateViewHostingController = UIHostingController(rootView: EmptyStateView(viewModel: emptyStateViewModel)) + addChild(emptyStateViewHostingController) + emptyStateViewHostingController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(emptyStateViewHostingController.view) + NSLayoutConstraint.activate([ + emptyStateViewHostingController.view.topAnchor.constraint(equalTo: view.topAnchor), + emptyStateViewHostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + emptyStateViewHostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + emptyStateViewHostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + emptyStateViewHostingController.view.isHidden = true + emptyStateViewModel.$emptyState + .map { $0 == nil } + .receive(on: DispatchQueue.main) + .assign(to: \.isHidden, on: emptyStateViewHostingController.view) + .store(in: &disposeBag) + + $listStatusTimelineViewController + .map { $0 == nil ? EmptyState.homeListNotSelected : nil } + .receive(on: DispatchQueue.main) + .assign(to: \.emptyState, on: emptyStateViewModel) + .store(in: &disposeBag) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + viewModel.viewDidAppear.send() + } + +} + +extension HomeListStatusTimelineViewController { + + @objc private func avatarButtonPressed(_ sender: UIButton) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + let drawerSidebarViewModel = DrawerSidebarViewModel(context: context, authContext: viewModel.authContext) + coordinator.present(scene: .drawerSidebar(viewModel: drawerSidebarViewModel), from: self, transition: .custom(animated: true, transitioningDelegate: drawerSidebarTransitionController)) + } + + private func selectListMenuAction(_ viewModel: HomeListStatusTimelineViewModel.HomeListMenuActionViewModel) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + } + + private func attachTimelineViewController(menuContext: HomeListStatusTimelineViewModel.HomeListMenuContext?) { + guard let menuContext = menuContext, + let activeMenuActionViewModel = menuContext.activeMenuActionViewModel + else { + detachTimeline() + return + } + + var isSameTimeline: Bool { + switch activeMenuActionViewModel.timeline { + case .home: + guard let _ = listStatusTimelineViewController?.viewModel as? HomeTimelineViewModel else { return false } + return true + case .list(let list): + guard let viewModel = listStatusTimelineViewController?.viewModel as? ListStatusTimelineViewModel else { return false } + return viewModel.list == list.asRecord + } + } + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): isSameTimeline: \(isSameTimeline)") + guard !isSameTimeline else { return } + + // detach + detachTimeline() + + // attach + let viewController: ListTimelineViewController = { + switch activeMenuActionViewModel.timeline { + case .home: + let viewController = HomeTimelineViewController() + viewController.context = context + viewController.coordinator = coordinator + viewController.viewModel = HomeTimelineViewModel( + context: context, + authContext: authContext + ) + return viewController + case .list(let list): + let viewController = ListStatusTimelineViewController() + viewController.context = context + viewController.coordinator = coordinator + viewController.viewModel = ListStatusTimelineViewModel( + context: context, + authContext: authContext, + list: list.asRecord + ) + return viewController + } + }() + self.listStatusTimelineViewController = viewController + self.title = activeMenuActionViewModel.title + + addChild(viewController) + viewController.willMove(toParent: self) + viewController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(viewController.view) + NSLayoutConstraint.activate([ + viewController.view.topAnchor.constraint(equalTo: view.topAnchor), + viewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + viewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + viewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + viewController.didMove(toParent: self) + } + + private func detachTimeline() { + listStatusTimelineViewController?.willMove(toParent: nil) + listStatusTimelineViewController?.view.removeFromSuperview() + listStatusTimelineViewController?.didMove(toParent: nil) + listStatusTimelineViewController?.removeFromParent() + title = L10n.Scene.Timeline.title + } + + private func reloadList() { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): reload owned list") + viewModel.ownedListViewModel.stateMachine.enter(ListViewModel.State.Reloading.self) + } + +} + +// MARK: - AuthContextProvider +extension HomeListStatusTimelineViewController: AuthContextProvider { + var authContext: AuthContext { viewModel.authContext } +} + +// MARK: - AvatarBarButtonItemDelegate +extension HomeListStatusTimelineViewController: AvatarBarButtonItemDelegate { } + +// MARK: - HomeListStatusTimelineViewModelDelegate +extension HomeListStatusTimelineViewController: HomeListStatusTimelineViewModelDelegate { + func homeListStatusTimelineViewModel( + _ viewModel: HomeListStatusTimelineViewModel, + menuActionDidSelect menuActionViewModel: HomeListStatusTimelineViewModel.HomeListMenuActionViewModel + ) { + switch menuActionViewModel.timeline { + case .home(let authenticationIndex): + let authenticationIndex = authenticationIndex.asRecrod + let managedObjectContext = context.backgroundManagedObjectContext + Task { + let now = Date() + try await managedObjectContext.performChanges { + guard let object = authenticationIndex.object(in: managedObjectContext) else { return } + object.update(homeTimelineActiveAt: now) + } + self.viewModel.homeTimelineMenuActionViewModels.first?.activeAt = now + self.viewModel.createHomeListMenuContext() + } // end Task + case .list(let list): + let list = list.asRecord + let managedObjectContext = context.backgroundManagedObjectContext + Task { + try await managedObjectContext.performChanges { + guard let object = list.object(in: managedObjectContext) else { return } + switch object { + case .twitter(let object): + object.update(activeAt: Date()) + case .mastodon(let object): + object.update(activeAt: Date()) + } // end switch + } + self.viewModel.createHomeListMenuContext() + } // end Task + } + } // end func +} diff --git a/TwidereX/Scene/List/HomeList/HostListStatusTimelineViewModel.swift b/TwidereX/Scene/List/HomeList/HostListStatusTimelineViewModel.swift new file mode 100644 index 00000000..bf31b429 --- /dev/null +++ b/TwidereX/Scene/List/HomeList/HostListStatusTimelineViewModel.swift @@ -0,0 +1,256 @@ +// +// HostListStatusTimelineViewModel.swift +// TwidereX +// +// Created by MainasuK on 2023/4/26. +// Copyright © 2023 Twidere. All rights reserved. +// + +import UIKit +import Combine +import CoreDataStack + +protocol HomeListStatusTimelineViewModelDelegate: AnyObject { + func homeListStatusTimelineViewModel(_ viewModel: HomeListStatusTimelineViewModel, menuActionDidSelect menuActionViewModel: HomeListStatusTimelineViewModel.HomeListMenuActionViewModel) +} + +final class HomeListStatusTimelineViewModel: ObservableObject { + + var disposeBag = Set() + + // input + let context: AppContext + let authContext: AuthContext + + let viewDidAppear = CurrentValueSubject(Void()) + + let ownedListViewModel: ListViewModel + let subscribedListViewModel: ListViewModel + + weak var delegate: HomeListStatusTimelineViewModelDelegate? + + // output + @Published var homeTimelineMenuActionViewModels: [HomeListMenuActionViewModel] + @Published var ownedListMenuActionViewModels: [HomeListMenuActionViewModel] = [] + @Published var subscribedListMenuActionViewModels: [HomeListMenuActionViewModel] = [] + @Published var homeListMenuContext: HomeListMenuContext? + + init( + context: AppContext, + authContext: AuthContext + ) { + self.context = context + self.authContext = authContext + if let me = authContext.authenticationContext.user(in: context.managedObjectContext)?.asRecord { + self.ownedListViewModel = { + let viewModel = ListViewModel(context: context, authContext: authContext, kind: .owned(user: me)) + viewModel.needsResetBeforeReloading = false + return viewModel + }() + self.subscribedListViewModel = { + let viewModel = ListViewModel(context: context, authContext: authContext, kind: .subscribed(user: me)) + viewModel.needsResetBeforeReloading = false + return viewModel + }() + } else { + self.ownedListViewModel = ListViewModel(context: context, authContext: authContext, kind: .none) + self.subscribedListViewModel = ListViewModel(context: context, authContext: authContext, kind: .none) + } + self.homeTimelineMenuActionViewModels = { + guard let authenticationIndex = authContext.authenticationContext.authenticationIndex(in: context.managedObjectContext) else { return [] } + switch authContext.authenticationContext.platform { + case .twitter: + return [] + case .mastodon: + return [HomeListMenuActionViewModel(timeline: .home(authenticationIndex))] + case .none: + return [] + } + }() + // end init + + Publishers.CombineLatest( + $ownedListMenuActionViewModels, + $subscribedListMenuActionViewModels + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] _, _ in + guard let self = self else { return } + Task { @MainActor in + _ = self.createHomeListMenuContext() + } // end Task + } + .store(in: &disposeBag) + + ownedListViewModel.fetchedResultController.$records + .receive(on: DispatchQueue.main) + .compactMap { [weak self] records -> [HomeListMenuActionViewModel]? in + guard let self = self else { return nil } + return records + .compactMap { record in record.object(in: self.context.managedObjectContext) } + .map { object in HomeListMenuActionViewModel(timeline: .list(object)) } + } + .assign(to: &$ownedListMenuActionViewModels) + subscribedListViewModel.fetchedResultController.$records + .receive(on: DispatchQueue.main) + .compactMap { [weak self] records -> [HomeListMenuActionViewModel]? in + guard let self = self else { return nil } + return records + .compactMap { record in record.object(in: self.context.managedObjectContext) } + .map { object in HomeListMenuActionViewModel(timeline: .list(object)) } + } + .assign(to: &$subscribedListMenuActionViewModels) + } + +} + +extension HomeListStatusTimelineViewModel { + class HomeListMenuActionViewModel: ObservableObject { + var disposeBag = Set() + + // input + let timeline: Timeline + + // output + @Published var title: String = "" + @Published var activeAt: Date? = nil + + init(timeline: Timeline) { + self.timeline = timeline + // end init + + setup(timeline: timeline) + } + + enum Timeline { + case home(AuthenticationIndex) + case list(ListObject) + } + } +} + +extension HomeListStatusTimelineViewModel.HomeListMenuActionViewModel { + func setup(timeline: Timeline) { + switch timeline { + case .home(let authenticationIndex): + title = L10n.Scene.Timeline.title + authenticationIndex.publisher(for: \.homeTimelineActiveAt) + .assign(to: \.activeAt, on: self) + .store(in: &disposeBag) + case .list(let list): + switch list { + case .twitter(let object): + setup(list: object) + case .mastodon(let object): + setup(list: object) + } + } + } + + func setup(list: TwitterList) { + list.publisher(for: \.name) + .assign(to: \.title, on: self) + .store(in: &disposeBag) + list.publisher(for: \.activeAt) + .assign(to: \.activeAt, on: self) + .store(in: &disposeBag) + } + + func setup(list: MastodonList) { + list.publisher(for: \.title) + .assign(to: \.title, on: self) + .store(in: &disposeBag) + list.publisher(for: \.activeAt) + .assign(to: \.activeAt, on: self) + .store(in: &disposeBag) + } +} + +extension HomeListStatusTimelineViewModel { + struct HomeListMenuContext { + let homeTimelineMenu: UIMenu + let ownedListMenu: UIMenu + let subscribedListMenu: UIMenu + let isEmpty: Bool + let activeMenuActionViewModel: HomeListMenuActionViewModel? + } +} + +extension HomeListStatusTimelineViewModel { + @MainActor + @discardableResult + func createHomeListMenuContext() -> HomeListMenuContext { + let homeTimelineMenuActionViewModels = self.homeTimelineMenuActionViewModels + let ownedListMenuActionViewModels = self.ownedListMenuActionViewModels + let subscribedListMenuActionViewModels = self.subscribedListMenuActionViewModels + + let latestActiveViewModel: HomeListMenuActionViewModel? = { + var menuActionViewModels: [HomeListMenuActionViewModel] = [] + menuActionViewModels.append(contentsOf: homeTimelineMenuActionViewModels) + menuActionViewModels.append(contentsOf: ownedListMenuActionViewModels) + menuActionViewModels.append(contentsOf: subscribedListMenuActionViewModels) + + var latestActiveViewModel = menuActionViewModels.first + for menuActionViewModel in menuActionViewModels { + guard let activeAt = menuActionViewModel.activeAt else { continue } + if let latestActiveAt = latestActiveViewModel?.activeAt { + if activeAt > latestActiveAt { + latestActiveViewModel = menuActionViewModel + } else { + continue + } + } else { + latestActiveViewModel = menuActionViewModel + } + } + return latestActiveViewModel + }() + + // home timeline + let homeTimelineMenuActions: [UIMenuElement] = homeTimelineMenuActionViewModels.map { viewModel in + let state: UIMenuElement.State = viewModel === latestActiveViewModel ? .on : .off + return UIAction(title: viewModel.title, state: state) { [weak self] _ in + guard let self = self else { return } + self.delegate?.homeListStatusTimelineViewModel(self, menuActionDidSelect: viewModel) + } + } + let homeTimelineMenu = UIMenu(title: "", options: .displayInline, children: homeTimelineMenuActions) + + // owned lists + let ownedListMenuActions: [UIMenuElement] = ownedListMenuActionViewModels.map { viewModel in + let state: UIMenuElement.State = viewModel === latestActiveViewModel ? .on : .off + return UIAction(title: viewModel.title, state: state) { [weak self] _ in + guard let self = self else { return } + self.delegate?.homeListStatusTimelineViewModel(self, menuActionDidSelect: viewModel) + } + } + let ownedListMenu = UIMenu(title: "Lists", options: .displayInline, children: ownedListMenuActions) + // subscribed lists + let subscribedListMenuActions: [UIMenuElement] = subscribedListMenuActionViewModels.map { viewModel in + let state: UIMenuElement.State = viewModel === latestActiveViewModel ? .on : .off + return UIAction(title: viewModel.title, state: state) { [weak self] _ in + guard let self = self else { return } + self.delegate?.homeListStatusTimelineViewModel(self, menuActionDidSelect: viewModel) + } + } + let subscribedListMenu = UIMenu(title: "Subscribed", options: .displayInline, children: subscribedListMenuActions) + + let isEmpty: Bool = { + guard ownedListMenuActions.isEmpty else { return false } + guard subscribedListMenuActions.isEmpty else { return false } + return true + }() + + let homeListMenuContext = HomeListMenuContext( + homeTimelineMenu: homeTimelineMenu, + ownedListMenu: ownedListMenu, + subscribedListMenu: subscribedListMenu, + isEmpty: isEmpty, + activeMenuActionViewModel: latestActiveViewModel + ) + self.homeListMenuContext = homeListMenuContext + + return homeListMenuContext + } // end func +} + diff --git a/TwidereX/Scene/List/List/ListViewController.swift b/TwidereX/Scene/List/List/ListViewController.swift index 68d0a152..36bc9495 100644 --- a/TwidereX/Scene/List/List/ListViewController.swift +++ b/TwidereX/Scene/List/List/ListViewController.swift @@ -91,7 +91,7 @@ extension ListViewController: UITableViewDelegate { guard let diffableDataSource = viewModel.diffableDataSource else { return } guard case let .list(record, _) = diffableDataSource.itemIdentifier(for: indexPath) else { return } - let listStatusViewModel = ListStatusTimelineViewModel(context: context, list: record) + let listStatusViewModel = ListStatusTimelineViewModel(context: context, authContext: viewModel.authContext, list: record) coordinator.present( scene: .listStatus(viewModel: listStatusViewModel), from: self, diff --git a/TwidereX/Scene/List/List/ListViewModel+Diffable.swift b/TwidereX/Scene/List/List/ListViewModel+Diffable.swift index 1c56b243..a0945249 100644 --- a/TwidereX/Scene/List/List/ListViewModel+Diffable.swift +++ b/TwidereX/Scene/List/List/ListViewModel+Diffable.swift @@ -22,10 +22,18 @@ extension ListViewModel { fetchedResultController.$records .receive(on: DispatchQueue.main) - .asyncMap { records -> NSDiffableDataSourceSnapshot? in + .asyncMap { [weak self] records -> NSDiffableDataSourceSnapshot? in + guard let self = self else { return nil } var snapshot = NSDiffableDataSourceSnapshot() - let section = ListSection.twitter(kind: .owned) + let section: ListSection = { + switch self.kind { + case .none: return ListSection.twitter(kind: .owned) + case .owned: return ListSection.twitter(kind: .owned) + case .subscribed: return ListSection.twitter(kind: .subscribed) + case .listed: return ListSection.twitter(kind: .listed) + } + }() snapshot.appendSections([section]) let items = records.map { ListItem.list(record: $0, style: .plain) } diff --git a/TwidereX/Scene/List/List/ListViewModel+State.swift b/TwidereX/Scene/List/List/ListViewModel+State.swift index 1f9cc796..cd15775c 100644 --- a/TwidereX/Scene/List/List/ListViewModel+State.swift +++ b/TwidereX/Scene/List/List/ListViewModel+State.swift @@ -71,7 +71,9 @@ extension ListViewModel.State { guard let viewModel = viewModel, let stateMachine = stateMachine else { return } // reset - viewModel.fetchedResultController.reset() + if viewModel.needsResetBeforeReloading { + viewModel.fetchedResultController.reset() + } stateMachine.enter(Loading.self) } @@ -80,6 +82,7 @@ extension ListViewModel.State { class Loading: ListViewModel.State { var nextInput: ListFetchViewModel.List.Input? + var nonce = UUID() override func isValidNextState(_ stateClass: AnyClass) -> Bool { switch stateClass { @@ -97,20 +100,23 @@ extension ListViewModel.State { override func didEnter(from previousState: GKState?) { super.didEnter(from: previousState) guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + nonce = UUID() + let nonce = self.nonce + let isReloading: Bool switch previousState { case is Reloading: nextInput = nil + isReloading = true default: - break + isReloading = false } - guard let authenticationContext = viewModel.context.authenticationService.activeAuthenticationContext, - let user = viewModel.kind.user - else { + guard let user = viewModel.kind.user else { stateMachine.enter(Fail.self) return } + let authenticationContext = viewModel.authContext.authenticationContext if nextInput == nil { nextInput = { @@ -147,32 +153,48 @@ extension ListViewModel.State { } // The state machine needs guard the Task is re-entry issue-free - Task { + Task { @MainActor in do { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch…") let output = try await ListFetchViewModel.List.list(api: viewModel.context.apiService, input: input) + + // check nonce + guard nonce == self.nonce else { + return + } + nextInput = output.nextInput if output.hasMore { - await enter(state: Idle.self) + enter(state: Idle.self) } else { - await enter(state: NoMore.self) + enter(state: NoMore.self) } switch output.result { case .twitter(let lists): let ids = lists.map { $0.id } - viewModel.fetchedResultController.twitterListRecordFetchedResultController.append(ids: ids) + if isReloading { + viewModel.fetchedResultController.twitterListRecordFetchedResultController.update(ids: ids) + } else { + viewModel.fetchedResultController.twitterListRecordFetchedResultController.append(ids: ids) + } case .mastodon(let lists): let ids = lists.map { $0.id } - viewModel.fetchedResultController.mastodonListRecordFetchedResultController.append(ids: ids) + if isReloading { + viewModel.fetchedResultController.mastodonListRecordFetchedResultController.update(ids: ids) + } else { + viewModel.fetchedResultController.mastodonListRecordFetchedResultController.append(ids: ids) + } } logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch success") + + viewModel.retryCount = 0 } catch { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch user timeline fail: \(error.localizedDescription)") - await enter(state: Fail.self) + enter(state: Fail.self) } } // end Task } // end func @@ -191,13 +213,17 @@ extension ListViewModel.State { override func didEnter(from previousState: GKState?) { super.didEnter(from: previousState) - guard let _ = viewModel, let stateMachine = stateMachine else { return } + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading 3s later…", ((#file as NSString).lastPathComponent), #line, #function) - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading", ((#file as NSString).lastPathComponent), #line, #function) - stateMachine.enter(Loading.self) - } + Task { @MainActor in + let delay = min(64.0, pow(2.0, Double(viewModel.retryCount))) + viewModel.retryCount += 1 + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading %.2fs later…", ((#file as NSString).lastPathComponent), #line, #function, delay) + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading", ((#file as NSString).lastPathComponent), #line, #function) + stateMachine.enter(Loading.self) + } + } // end Task } } diff --git a/TwidereX/Scene/List/List/ListViewModel.swift b/TwidereX/Scene/List/List/ListViewModel.swift index 107d87fb..16aef448 100644 --- a/TwidereX/Scene/List/List/ListViewModel.swift +++ b/TwidereX/Scene/List/List/ListViewModel.swift @@ -18,10 +18,13 @@ class ListViewModel { // input let context: AppContext + let authContext: AuthContext let kind: Kind let fetchedResultController: ListRecordFetchedResultController let listBatchFetchViewModel = ListBatchFetchViewModel() + var needsResetBeforeReloading = true + // output var diffableDataSource: UITableViewDiffableDataSource? @@ -38,12 +41,15 @@ class ListViewModel { stateMachine.enter(State.Initial.self) return stateMachine }() + @MainActor var retryCount = 0 init( context: AppContext, + authContext: AuthContext, kind: Kind ) { self.context = context + self.authContext = authContext self.kind = kind self.fetchedResultController = ListRecordFetchedResultController(managedObjectContext: context.managedObjectContext) // end init diff --git a/TwidereX/Scene/List/ListUser/ListUserViewController.swift b/TwidereX/Scene/List/ListUser/ListUserViewController.swift index fecd8689..a38cc62d 100644 --- a/TwidereX/Scene/List/ListUser/ListUserViewController.swift +++ b/TwidereX/Scene/List/ListUser/ListUserViewController.swift @@ -10,7 +10,6 @@ import os.log import UIKit import Combine import CoreDataStack -import TwidereUI import SwiftMessages final class ListUserViewController: UIViewController, NeedsDependency { @@ -49,29 +48,23 @@ extension ListUserViewController { title = viewModel.kind.title view.backgroundColor = .systemBackground - context.authenticationService.$activeAuthenticationContext - .asyncMap { [weak self] authenticationContext -> UIBarButtonItem? in - guard let self = self else { return nil } - guard let authenticationContext = authenticationContext else { return nil } - // only setup bar button for `members` kind list - switch self.viewModel.kind { - case .members: break - case .subscribers: return nil - } + + let rightBarButtonItem: UIBarButtonItem? = { + // only setup bar button for `members` kind list + switch self.viewModel.kind { + case .members: // only setup bar button for myList - let managedObjectContext = self.context.managedObjectContext - let isMyList: Bool = await managedObjectContext.perform { + let isMyList: Bool = { + let managedObjectContext = self.context.managedObjectContext guard let list = self.viewModel.kind.list.object(in: managedObjectContext) else { return false } - return list.owner.userIdentifer == authenticationContext.userIdentifier - } + return list.owner.userIdentifer == viewModel.authContext.authenticationContext.userIdentifier + }() return isMyList ? self.addBarButtonItem : nil + case .subscribers: + return nil } - .receive(on: DispatchQueue.main) - .sink { [weak self] barButtonItem in - guard let self = self else { return } - self.navigationItem.rightBarButtonItem = barButtonItem - } - .store(in: &disposeBag) + }() + self.navigationItem.rightBarButtonItem = rightBarButtonItem tableView.translatesAutoresizingMaskIntoConstraints = false tableView.frame = view.bounds @@ -98,6 +91,8 @@ extension ListUserViewController { self.viewModel.stateMachine.enter(ListUserViewModel.State.Loading.self) } .store(in: &disposeBag) + + viewModel.listMembershipViewModel.delegate = self } @@ -115,7 +110,7 @@ extension ListUserViewController { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") let list = viewModel.kind.list - let addListMemberViewModel = AddListMemberViewModel(context: context, list: list) + let addListMemberViewModel = AddListMemberViewModel(context: context, authContext: authContext, list: list) addListMemberViewModel.listMembershipViewModelDelegate = self coordinator.present( @@ -140,79 +135,27 @@ 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 - } - - guard let authenticationContext = self.context.authenticationService.activeAuthenticationContext else { - assertionFailure() - return - } - - 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 } } + +// MARK: - AuthContextProvider +extension ListUserViewController: AuthContextProvider { + var authContext: AuthContext { viewModel.authContext } +} diff --git a/TwidereX/Scene/List/ListUser/ListUserViewModel+Diffable.swift b/TwidereX/Scene/List/ListUser/ListUserViewModel+Diffable.swift index aeb36a5c..bc0af447 100644 --- a/TwidereX/Scene/List/ListUser/ListUserViewModel+Diffable.swift +++ b/TwidereX/Scene/List/ListUser/ListUserViewModel+Diffable.swift @@ -8,7 +8,6 @@ import UIKit import Combine -import TwidereUI extension ListUserViewModel { @@ -19,12 +18,9 @@ extension ListUserViewModel { diffableDataSource = UserSection.diffableDataSource( tableView: tableView, context: context, + authContext: authContext, configuration: UserSection.Configuration( - userViewTableViewCellDelegate: userViewTableViewCellDelegate, - userViewConfigurationContext: .init( - listMembershipViewModel: listMembershipViewModel, - authenticationContext: context.authenticationService.activeAuthenticationContext - ) + userViewTableViewCellDelegate: userViewTableViewCellDelegate ) ) @@ -36,7 +32,9 @@ extension ListUserViewModel { snapshot.appendSections([.main]) - let items = records.map { UserItem.user(record: $0, style: .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/List/ListUser/ListUserViewModel+State.swift b/TwidereX/Scene/List/ListUser/ListUserViewModel+State.swift index 24e74e64..5816c2fb 100644 --- a/TwidereX/Scene/List/ListUser/ListUserViewModel+State.swift +++ b/TwidereX/Scene/List/ListUser/ListUserViewModel+State.swift @@ -105,11 +105,7 @@ extension ListUserViewModel.State { break } - guard let authenticationContext = viewModel.context.authenticationService.activeAuthenticationContext else { - stateMachine.enter(Fail.self) - return - } - + let authenticationContext = viewModel.authContext.authenticationContext let list = viewModel.kind.list if nextInput == nil { diff --git a/TwidereX/Scene/List/ListUser/ListUserViewModel.swift b/TwidereX/Scene/List/ListUser/ListUserViewModel.swift index 70db12c6..3e05a99b 100644 --- a/TwidereX/Scene/List/ListUser/ListUserViewModel.swift +++ b/TwidereX/Scene/List/ListUser/ListUserViewModel.swift @@ -20,6 +20,7 @@ final class ListUserViewModel { // input let context: AppContext + let authContext: AuthContext let kind: Kind let fetchedResultController: UserRecordFetchedResultController let listMembershipViewModel: ListMembershipViewModel @@ -44,9 +45,11 @@ final class ListUserViewModel { init( context: AppContext, + authContext: AuthContext, kind: Kind ) { self.context = context + self.authContext = authContext self.kind = kind self.fetchedResultController = UserRecordFetchedResultController(managedObjectContext: context.managedObjectContext) self.listMembershipViewModel = ListMembershipViewModel(api: context.apiService, list: kind.list) diff --git a/TwidereX/Scene/MediaPreview/MediaPreviewViewController.swift b/TwidereX/Scene/MediaPreview/MediaPreviewViewController.swift index 4f1bf6d3..8936ae3f 100644 --- a/TwidereX/Scene/MediaPreview/MediaPreviewViewController.swift +++ b/TwidereX/Scene/MediaPreview/MediaPreviewViewController.swift @@ -11,7 +11,6 @@ import UIKit import Combine import CoreData import CoreDataStack -import AppShared import AlamofireImage import Kingfisher import Pageboy @@ -123,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 @@ -135,18 +135,20 @@ extension MediaPreviewViewController { pageControl.bottomAnchor.constraint(equalTo: pageControlBackgroundVisualEffectView.bottomAnchor), ]) - if let status = viewModel.status { - mediaInfoDescriptionView.configure( - statusObject: status, - configurationContext: .init( - dateTimeProvider: DateTimeSwiftProvider(), - twitterTextProvider: OfficialTwitterTextProvider(), - authenticationContext: context.authenticationService.$activeAuthenticationContext - ) - ) - } else { - mediaInfoDescriptionView.isHidden = true - } + mediaInfoDescriptionView.isHidden = true + +// if let status = viewModel.status { +// mediaInfoDescriptionView.configure( +// statusObject: status, +// configurationContext: .init( +// dateTimeProvider: DateTimeSwiftProvider(), +// twitterTextProvider: OfficialTwitterTextProvider(), +// viewLayoutFramePublisher: viewModel.$viewLayoutFrame +// ) +// ) +// } else { +// mediaInfoDescriptionView.isHidden = true +// } pageControl.numberOfPages = viewModel.viewControllers.count pageControl.isHidden = viewModel.viewControllers.count == 1 @@ -171,20 +173,15 @@ extension MediaPreviewViewController { // 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 - } + // 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) @@ -326,15 +323,20 @@ extension MediaPreviewViewController: MediaPreviewImageViewControllerDelegate { impactFeedbackGenerator.impactOccurred() // trigger menu button action - mediaInfoDescriptionView.toolbar.delegate?.statusToolbar( - mediaInfoDescriptionView.toolbar, - actionDidPressed: .menu, - button: mediaInfoDescriptionView.toolbar.menuButton - ) +// mediaInfoDescriptionView.toolbar.delegate?.statusToolbar( +// mediaInfoDescriptionView.toolbar, +// actionDidPressed: .menu, +// button: mediaInfoDescriptionView.toolbar.menuButton +// ) } } +// MARK: - AuthContextProvider +extension MediaPreviewViewController: AuthContextProvider { + var authContext: AuthContext { viewModel.authContext } +} + // MARK: - MediaInfoDescriptionViewDelegate extension MediaPreviewViewController: MediaInfoDescriptionViewDelegate { } diff --git a/TwidereX/Scene/MediaPreview/MediaPreviewViewModel.swift b/TwidereX/Scene/MediaPreview/MediaPreviewViewModel.swift index b0bda317..1b33ad6d 100644 --- a/TwidereX/Scene/MediaPreview/MediaPreviewViewModel.swift +++ b/TwidereX/Scene/MediaPreview/MediaPreviewViewModel.swift @@ -12,7 +12,6 @@ import CoreData import CoreDataStack import Pageboy import TwidereCore -import TwidereUI final class MediaPreviewViewModel: NSObject { @@ -22,9 +21,12 @@ final class MediaPreviewViewModel: NSObject { // input let context: AppContext + let authContext: AuthContext let item: Item let transitionItem: MediaPreviewTransitionItem + @Published public var viewLayoutFrame = ViewLayoutFrame() + @Published var currentPage: Int // output @@ -33,14 +35,16 @@ final class MediaPreviewViewModel: NSObject { init( context: AppContext, + authContext: AuthContext, item: Item, transitionItem: MediaPreviewTransitionItem ) { self.context = context + self.authContext = authContext self.item = item self.currentPage = { switch item { - case .statusAttachment(let previewContext): + case .statusMedia(let previewContext): return previewContext.initialIndex case .image: return 0 @@ -50,7 +54,7 @@ final class MediaPreviewViewModel: NSObject { // setup output self.status = { switch item { - case .statusAttachment(let previewContext): + case .statusMedia(let previewContext): let status = previewContext.status.object(in: context.managedObjectContext) return status case .image: @@ -60,15 +64,15 @@ final class MediaPreviewViewModel: NSObject { self.viewControllers = { var viewControllers: [UIViewController] = [] switch item { - case .statusAttachment(let previewContext): - for (i, attachment) in previewContext.attachments.enumerated() { - switch attachment.kind { - case .image: + case .statusMedia(let previewContext): + for (i, mediaViewModel) in previewContext.mediaViewModels.enumerated() { + switch mediaViewModel.mediaKind { + case .photo: let viewController = MediaPreviewImageViewController() viewController.viewModel = MediaPreviewImageViewModel( context: context, item: .remote(.init( - assetURL: attachment.assetURL, + assetURL: mediaViewModel.assetURL, thumbnail: previewContext.thumbnail(at: i) )) ) @@ -78,23 +82,21 @@ final class MediaPreviewViewModel: NSObject { viewController.viewModel = MediaPreviewVideoViewModel( context: context, item: .video(.init( - assetURL: attachment.assetURL, - previewURL: attachment.previewURL + assetURL: mediaViewModel.assetURL, + previewURL: mediaViewModel.previewURL )) ) viewControllers.append(viewController) - case .gif: + case .animatedGIF: let viewController = MediaPreviewVideoViewController() viewController.viewModel = MediaPreviewVideoViewModel( context: context, item: .gif(.init( - assetURL: attachment.assetURL, - previewURL: attachment.previewURL + assetURL: mediaViewModel.assetURL, + previewURL: mediaViewModel.previewURL )) ) viewControllers.append(viewController) - case .audio: - viewControllers.append(UIViewController()) } } case .image(let previewContext): @@ -127,13 +129,13 @@ final class MediaPreviewViewModel: NSObject { extension MediaPreviewViewModel { enum Item { - case statusAttachment(StatusAttachmentPreviewContext) + case statusMedia(StatusMediaPreviewContext) case image(ImagePreviewContext) } - struct StatusAttachmentPreviewContext { + struct StatusMediaPreviewContext { let status: StatusRecord - let attachments: [AttachmentObject] + let mediaViewModels: [MediaView.ViewModel] let initialIndex: Int let preloadThumbnails: [UIImage?] @@ -166,7 +168,7 @@ extension MediaPreviewViewModel: PageboyViewControllerDataSource { func defaultPage(for pageboyViewController: PageboyViewController) -> PageboyViewController.Page? { switch item { - case .statusAttachment(let previewContext): + case .statusMedia(let previewContext): return .at(index: previewContext.initialIndex) case .image: return .first diff --git a/TwidereX/Scene/MediaPreview/Video/MediaPreviewVideoViewController.swift b/TwidereX/Scene/MediaPreview/Video/MediaPreviewVideoViewController.swift index 9e2d04fc..b5342f16 100644 --- a/TwidereX/Scene/MediaPreview/Video/MediaPreviewVideoViewController.swift +++ b/TwidereX/Scene/MediaPreview/Video/MediaPreviewVideoViewController.swift @@ -68,6 +68,7 @@ extension MediaPreviewVideoViewController { switch viewModel.item { case .gif: playerViewController.showsPlaybackControls = false + playerViewController.view.isUserInteractionEnabled = false // disable pan to seek time default: break } @@ -92,6 +93,13 @@ extension MediaPreviewVideoViewController { } } + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + // fix player not respect safe area issue + playerViewController.didMove(toParent: self) + } + } // MARK: - ShareActivityProvider diff --git a/TwidereX/Scene/MediaPreview/View/MediaInfoDescriptionView+ViewModel.swift b/TwidereX/Scene/MediaPreview/View/MediaInfoDescriptionView+ViewModel.swift index 3559651b..6af25d85 100644 --- a/TwidereX/Scene/MediaPreview/View/MediaInfoDescriptionView+ViewModel.swift +++ b/TwidereX/Scene/MediaPreview/View/MediaInfoDescriptionView+ViewModel.swift @@ -10,8 +10,6 @@ import os.log import UIKit import Combine import CoreDataStack -import AppShared -import TwidereCore import TwitterMeta import MastodonMeta import Meta @@ -35,7 +33,7 @@ extension MediaInfoDescriptionView { @Published public var content: MetaContent? - @Published public var visibility: StatusVisibility? + @Published public var visibility: MastodonVisibility? @Published public var isRepost = false @Published public var isRepostEnabled = true @@ -80,7 +78,7 @@ extension MediaInfoDescriptionView { return !protected case .mastodon: guard !isMyself else { return true } - guard case let .mastodon(visibility) = visibility else { + guard let visibility = visibility else { return true } switch visibility { @@ -98,273 +96,266 @@ extension MediaInfoDescriptionView { extension MediaInfoDescriptionView.ViewModel { func bind(view: MediaInfoDescriptionView) { - // avatar - $authorAvatarImageURL - .sink { url in - let configuration = AvatarImageView.Configuration(url: url) - view.avatarView.avatarButton.avatarImageView.configure(configuration: configuration) - } - .store(in: &disposeBag) - UserDefaults.shared - .observe(\.avatarStyle, options: [.initial, .new]) { defaults, _ in - let avatarStyle = defaults.avatarStyle - let animator = UIViewPropertyAnimator(duration: 0.3, timingParameters: UISpringTimingParameters()) - animator.addAnimations { [weak view] in - guard let view = view else { return } - switch avatarStyle { - case .circle: - view.avatarView.avatarButton.avatarImageView.configure(cornerConfiguration: .init(corner: .circle)) - case .roundedSquare: - view.avatarView.avatarButton.avatarImageView.configure(cornerConfiguration: .init(corner: .scale(ratio: 4))) - } - } - animator.startAnimation() - } - .store(in: &observations) - // name - $authorName - .sink { metaContent in - let metaContent = metaContent ?? PlaintextMetaContent(string: "") - view.nameMetaLabel.setupAttributes(style: StatusView.authorNameLabelStyle) - view.nameMetaLabel.configure(content: metaContent) - } - .store(in: &disposeBag) - // content - $content - .sink { metaContent in - guard let content = metaContent else { - view.contentTextView.reset() - return - } - view.contentTextView.configure(content: content) - } - .store(in: &disposeBag) - // toolbar - $platform - .assign(to: \.platform, on: view.toolbar.viewModel) - .store(in: &disposeBag) - Publishers.CombineLatest( - $isRepost, - $isRepostEnabled - ) - .sink { isRepost, isEnabled in - view.toolbar.setupRepost(count: 0, isEnabled: isEnabled, isHighlighted: isRepost) - } - .store(in: &disposeBag) - $isLike - .sink { isLike in - view.toolbar.setupLike(count: 0, isHighlighted: isLike) - } - .store(in: &disposeBag) +// // avatar +// $authorAvatarImageURL +// .sink { url in +// let configuration = AvatarImageView.Configuration(url: url) +// view.avatarView.avatarButton.avatarImageView.configure(configuration: configuration) +// } +// .store(in: &disposeBag) +// UserDefaults.shared +// .observe(\.avatarStyle, options: [.initial, .new]) { defaults, _ in +// let avatarStyle = defaults.avatarStyle +// let animator = UIViewPropertyAnimator(duration: 0.3, timingParameters: UISpringTimingParameters()) +// animator.addAnimations { [weak view] in +// guard let view = view else { return } +// switch avatarStyle { +// case .circle: +// view.avatarView.avatarButton.avatarImageView.configure(cornerConfiguration: .init(corner: .circle)) +// case .roundedSquare: +// view.avatarView.avatarButton.avatarImageView.configure(cornerConfiguration: .init(corner: .scale(ratio: 4))) +// } +// } +// animator.startAnimation() +// } +// .store(in: &observations) +// // name +// $authorName +// .sink { metaContent in +// let metaContent = metaContent ?? PlaintextMetaContent(string: "") +// view.nameMetaLabel.setupAttributes(style: StatusView.authorNameLabelStyle) +// view.nameMetaLabel.configure(content: metaContent) +// } +// .store(in: &disposeBag) +// // content +// $content +// .sink { metaContent in +// guard let content = metaContent else { +// view.contentTextView.reset() +// return +// } +// view.contentTextView.configure(content: content) +// } +// .store(in: &disposeBag) +// // toolbar +// $platform +// .assign(to: \.platform, on: view.toolbar.viewModel) +// .store(in: &disposeBag) +// Publishers.CombineLatest( +// $isRepost, +// $isRepostEnabled +// ) +// .sink { isRepost, isEnabled in +// view.toolbar.setupRepost(count: 0, isEnabled: isEnabled, isHighlighted: isRepost) +// } +// .store(in: &disposeBag) +// $isLike +// .sink { isLike in +// view.toolbar.setupLike(count: 0, isHighlighted: isLike) +// } +// .store(in: &disposeBag) } } - -extension MediaInfoDescriptionView { - public typealias ConfigurationContext = StatusView.ConfigurationContext -} - extension MediaInfoDescriptionView { public func configure( - statusObject object: StatusObject, - configurationContext: ConfigurationContext + statusObject object: StatusObject + // configurationContext: ConfigurationContext ) { - switch object { - case .twitter(let status): - configure( - twitterStatus: status, - configurationContext: configurationContext - ) - case .mastodon(let status): - configure( - mastodonStatus: status, - configurationContext: configurationContext - ) - } +// switch object { +// case .twitter(let status): +// configure( +// twitterStatus: status, +// configurationContext: configurationContext +// ) +// case .mastodon(let status): +// configure( +// mastodonStatus: status, +// configurationContext: configurationContext +// ) +// } } } extension MediaInfoDescriptionView { - public func configure( - twitterStatus status: TwitterStatus, - configurationContext: ConfigurationContext - ) { - viewModel.platform = .twitter - viewModel.dateTimeProvider = configurationContext.dateTimeProvider - viewModel.twitterTextProvider = configurationContext.twitterTextProvider - configurationContext.authenticationContext.assign(to: \.authenticationContext, on: viewModel).store(in: &disposeBag) - - configureAuthor(twitterStatus: status) - configureContent(twitterStatus: status) - configureToolbar(twitterStatus: status) - } - - private func configureAuthor(twitterStatus status: TwitterStatus) { - let author = (status.repost ?? status).author - - // author avatar - author.publisher(for: \.profileImageURL) - .map { _ in author.avatarImageURL() } - .assign(to: \.authorAvatarImageURL, on: viewModel) - .store(in: &disposeBag) - // lock - author.publisher(for: \.protected) - .assign(to: \.protected, on: viewModel) - .store(in: &disposeBag) - // author name - author.publisher(for: \.name) - .map { PlaintextMetaContent(string: $0) } - .assign(to: \.authorName, on: viewModel) - .store(in: &disposeBag) - } - - private func configureContent(twitterStatus status: TwitterStatus) { - guard let twitterTextProvider = viewModel.twitterTextProvider else { - assertionFailure() - return - } - - let status = status.repost ?? status - let content = TwitterContent(content: status.text) - let metaContent = TwitterMetaContent.convert( - content: content, - urlMaximumLength: 20, - twitterTextProvider: twitterTextProvider - ) - viewModel.content = metaContent - viewModel.visibility = nil - } - - private func configureToolbar(twitterStatus status: TwitterStatus) { - let status = status.repost ?? status - - // relationship - Publishers.CombineLatest( - viewModel.$authenticationContext, - status.publisher(for: \.repostBy) - ) - .map { authenticationContext, repostBy in - guard let authenticationContext = authenticationContext?.twitterAuthenticationContext else { - return false - } - let userID = authenticationContext.userID - return repostBy.contains(where: { $0.id == userID }) - } - .assign(to: \.isRepost, on: viewModel) - .store(in: &disposeBag) - - Publishers.CombineLatest( - viewModel.$authenticationContext, - status.publisher(for: \.likeBy) - ) - .map { authenticationContext, likeBy in - guard let authenticationContext = authenticationContext?.twitterAuthenticationContext else { - return false - } - let userID = authenticationContext.userID - return likeBy.contains(where: { $0.id == userID }) - } - .assign(to: \.isLike, on: viewModel) - .store(in: &disposeBag) - } +// public func configure( +// twitterStatus status: TwitterStatus, +// configurationContext: ConfigurationContext +// ) { +// viewModel.platform = .twitter +// viewModel.dateTimeProvider = configurationContext.dateTimeProvider +// viewModel.twitterTextProvider = configurationContext.twitterTextProvider +// +// configureAuthor(twitterStatus: status) +// configureContent(twitterStatus: status) +// configureToolbar(twitterStatus: status) +// } +// +// private func configureAuthor(twitterStatus status: TwitterStatus) { +// let author = (status.repost ?? status).author +// +// // author avatar +// author.publisher(for: \.profileImageURL) +// .map { _ in author.avatarImageURL() } +// .assign(to: \.authorAvatarImageURL, on: viewModel) +// .store(in: &disposeBag) +// // lock +// author.publisher(for: \.protected) +// .assign(to: \.protected, on: viewModel) +// .store(in: &disposeBag) +// // author name +// author.publisher(for: \.name) +// .map { PlaintextMetaContent(string: $0) } +// .assign(to: \.authorName, on: viewModel) +// .store(in: &disposeBag) +// } +// +// private func configureContent(twitterStatus status: TwitterStatus) { +// guard let twitterTextProvider = viewModel.twitterTextProvider else { +// assertionFailure() +// return +// } +// +// let status = status.repost ?? status +// let content = TwitterContent(content: status.text) +// let metaContent = TwitterMetaContent.convert( +// content: content, +// urlMaximumLength: 20, +// twitterTextProvider: twitterTextProvider +// ) +// viewModel.content = metaContent +// viewModel.visibility = nil +// } +// +// private func configureToolbar(twitterStatus status: TwitterStatus) { +// let status = status.repost ?? status +// +// // relationship +// Publishers.CombineLatest( +// viewModel.$authenticationContext, +// status.publisher(for: \.repostBy) +// ) +// .map { authenticationContext, repostBy in +// guard let authenticationContext = authenticationContext?.twitterAuthenticationContext else { +// return false +// } +// let userID = authenticationContext.userID +// return repostBy.contains(where: { $0.id == userID }) +// } +// .assign(to: \.isRepost, on: viewModel) +// .store(in: &disposeBag) +// +// Publishers.CombineLatest( +// viewModel.$authenticationContext, +// status.publisher(for: \.likeBy) +// ) +// .map { authenticationContext, likeBy in +// guard let authenticationContext = authenticationContext?.twitterAuthenticationContext else { +// return false +// } +// let userID = authenticationContext.userID +// return likeBy.contains(where: { $0.id == userID }) +// } +// .assign(to: \.isLike, on: viewModel) +// .store(in: &disposeBag) +// } } // MARK: - Mastodon extension MediaInfoDescriptionView { - public func configure( - mastodonStatus status: MastodonStatus, - configurationContext: ConfigurationContext - ) { - viewModel.platform = .mastodon - viewModel.dateTimeProvider = configurationContext.dateTimeProvider - viewModel.twitterTextProvider = configurationContext.twitterTextProvider - configurationContext.authenticationContext.assign(to: \.authenticationContext, on: viewModel).store(in: &disposeBag) - -// configureHeader(mastodonStatus: status, mastodonNotification: notification) - configureAuthor(mastodonStatus: status) - configureContent(mastodonStatus: status) -// configureMedia(mastodonStatus: status) - configureToolbar(mastodonStatus: status) - } - - private func configureAuthor(mastodonStatus status: MastodonStatus) { - let author = (status.repost ?? status).author - - // author avatar - author.publisher(for: \.avatar) - .map { url in url.flatMap { URL(string: $0) } } - .assign(to: \.authorAvatarImageURL, on: viewModel) - .store(in: &disposeBag) - // author name - Publishers.CombineLatest( - author.publisher(for: \.displayName), - author.publisher(for: \.emojis) - ) - .map { _, emojis in - let content = MastodonContent(content: author.name, emojis: emojis.asDictionary) - do { - let metaContent = try MastodonMetaContent.convert(document: content) - return metaContent - } catch { - assertionFailure(error.localizedDescription) - return PlaintextMetaContent(string: author.name) - } - } - .assign(to: \.authorName, on: viewModel) - .store(in: &disposeBag) - // protected - author.publisher(for: \.locked) - .assign(to: \.protected, on: viewModel) - .store(in: &disposeBag) - - } - - private func configureContent(mastodonStatus status: MastodonStatus) { - let status = status.repost ?? status - let content = MastodonContent(content: status.content, emojis: status.emojis.asDictionary) - do { - let metaContent = try MastodonMetaContent.convert(document: content) - viewModel.content = metaContent - } catch { - assertionFailure(error.localizedDescription) - viewModel.content = PlaintextMetaContent(string: "") - } - - viewModel.visibility = status.visibility.asStatusVisibility - } +// public func configure( +// mastodonStatus status: MastodonStatus, +// configurationContext: ConfigurationContext +// ) { +// viewModel.platform = .mastodon +// viewModel.dateTimeProvider = configurationContext.dateTimeProvider +// viewModel.twitterTextProvider = configurationContext.twitterTextProvider +// +//// configureHeader(mastodonStatus: status, mastodonNotification: notification) +// configureAuthor(mastodonStatus: status) +// configureContent(mastodonStatus: status) +//// configureMedia(mastodonStatus: status) +// configureToolbar(mastodonStatus: status) +// } - private func configureToolbar(mastodonStatus status: MastodonStatus) { - let status = status.repost ?? status - - // relationship - Publishers.CombineLatest( - viewModel.$authenticationContext, - status.publisher(for: \.repostBy) - ) - .map { authenticationContext, repostBy in - guard let authenticationContext = authenticationContext?.mastodonAuthenticationContext else { - return false - } - let domain = authenticationContext.domain - let userID = authenticationContext.userID - return repostBy.contains(where: { $0.id == userID && $0.domain == domain }) - } - .assign(to: \.isRepost, on: viewModel) - .store(in: &disposeBag) - - Publishers.CombineLatest( - viewModel.$authenticationContext, - status.publisher(for: \.likeBy) - ) - .map { authenticationContext, likeBy in - guard let authenticationContext = authenticationContext?.mastodonAuthenticationContext else { - return false - } - let domain = authenticationContext.domain - let userID = authenticationContext.userID - return likeBy.contains(where: { $0.id == userID && $0.domain == domain }) - } - .assign(to: \.isLike, on: viewModel) - .store(in: &disposeBag) - } +// private func configureAuthor(mastodonStatus status: MastodonStatus) { +// let author = (status.repost ?? status).author +// +// // author avatar +// author.publisher(for: \.avatar) +// .map { url in url.flatMap { URL(string: $0) } } +// .assign(to: \.authorAvatarImageURL, on: viewModel) +// .store(in: &disposeBag) +// // author name +// Publishers.CombineLatest( +// author.publisher(for: \.displayName), +// author.publisher(for: \.emojis) +// ) +// .map { _, emojis in +// let content = MastodonContent(content: author.name, emojis: emojis.asDictionary) +// do { +// let metaContent = try MastodonMetaContent.convert(document: content) +// return metaContent +// } catch { +// assertionFailure(error.localizedDescription) +// return PlaintextMetaContent(string: author.name) +// } +// } +// .assign(to: \.authorName, on: viewModel) +// .store(in: &disposeBag) +// // protected +// author.publisher(for: \.locked) +// .assign(to: \.protected, on: viewModel) +// .store(in: &disposeBag) +// +// } +// +// private func configureContent(mastodonStatus status: MastodonStatus) { +// let status = status.repost ?? status +// let content = MastodonContent(content: status.content, emojis: status.emojis.asDictionary) +// do { +// let metaContent = try MastodonMetaContent.convert(document: content) +// viewModel.content = metaContent +// } catch { +// assertionFailure(error.localizedDescription) +// viewModel.content = PlaintextMetaContent(string: "") +// } +// +// viewModel.visibility = status.visibility +// } +// +// private func configureToolbar(mastodonStatus status: MastodonStatus) { +// let status = status.repost ?? status +// +// // relationship +// Publishers.CombineLatest( +// viewModel.$authenticationContext, +// status.publisher(for: \.repostBy) +// ) +// .map { authenticationContext, repostBy in +// guard let authenticationContext = authenticationContext?.mastodonAuthenticationContext else { +// return false +// } +// let domain = authenticationContext.domain +// let userID = authenticationContext.userID +// return repostBy.contains(where: { $0.id == userID && $0.domain == domain }) +// } +// .assign(to: \.isRepost, on: viewModel) +// .store(in: &disposeBag) +// +// Publishers.CombineLatest( +// viewModel.$authenticationContext, +// status.publisher(for: \.likeBy) +// ) +// .map { authenticationContext, likeBy in +// guard let authenticationContext = authenticationContext?.mastodonAuthenticationContext else { +// return false +// } +// let domain = authenticationContext.domain +// let userID = authenticationContext.userID +// return likeBy.contains(where: { $0.id == userID && $0.domain == domain }) +// } +// .assign(to: \.isLike, on: viewModel) +// .store(in: &disposeBag) +// } } diff --git a/TwidereX/Scene/MediaPreview/View/MediaInfoDescriptionView.swift b/TwidereX/Scene/MediaPreview/View/MediaInfoDescriptionView.swift index e3216b54..846feb8c 100644 --- a/TwidereX/Scene/MediaPreview/View/MediaInfoDescriptionView.swift +++ b/TwidereX/Scene/MediaPreview/View/MediaInfoDescriptionView.swift @@ -11,14 +11,14 @@ import UIKit import Combine import MetaTextKit import MetaTextArea -import TwidereUI +import MetaLabel protocol MediaInfoDescriptionViewDelegate: AnyObject { func mediaInfoDescriptionView(_ mediaInfoDescriptionView: MediaInfoDescriptionView, avatarButtonDidPressed button: UIButton) func mediaInfoDescriptionView(_ mediaInfoDescriptionView: MediaInfoDescriptionView, contentTextViewDidPressed textView: MetaTextAreaView) func mediaInfoDescriptionView(_ mediaInfoDescriptionView: MediaInfoDescriptionView, nameMetaLabelDidPressed metaLabel: MetaLabel) - func mediaInfoDescriptionView(_ mediaInfoDescriptionView: MediaInfoDescriptionView, statusToolbar: StatusToolbar, actionDidPressed action: StatusToolbar.Action, button: UIButton) - func mediaInfoDescriptionView(_ mediaInfoDescriptionView: MediaInfoDescriptionView, statusToolbar: StatusToolbar, menuActionDidPressed action: StatusToolbar.MenuAction, menuButton button: UIButton) +// func mediaInfoDescriptionView(_ mediaInfoDescriptionView: MediaInfoDescriptionView, statusToolbar: StatusToolbar, actionDidPressed action: StatusToolbar.Action, button: UIButton) +// func mediaInfoDescriptionView(_ mediaInfoDescriptionView: MediaInfoDescriptionView, statusToolbar: StatusToolbar, menuActionDidPressed action: StatusToolbar.MenuAction, menuButton button: UIButton) } final class MediaInfoDescriptionView: UIView { @@ -66,11 +66,11 @@ final class MediaInfoDescriptionView: UIView { return textView }() - let toolbar: StatusToolbar = { - let toolbar = StatusToolbar() - toolbar.setup(style: .plain) - return toolbar - }() +// let toolbar: StatusToolbar = { +// let toolbar = StatusToolbar() +// toolbar.setup(style: .plain) +// return toolbar +// }() let contentTextViewTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer let nameMetaLabelTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer @@ -124,11 +124,11 @@ extension MediaInfoDescriptionView { avatarView.heightAnchor.constraint(equalToConstant: MediaInfoDescriptionView.avatarImageViewSize.height).priority(.required - 1), ]) bottomContainerStackView.addArrangedSubview(nameMetaLabel) - toolbar.translatesAutoresizingMaskIntoConstraints = false - bottomContainerStackView.addArrangedSubview(toolbar) - NSLayoutConstraint.activate([ - toolbar.widthAnchor.constraint(equalToConstant: 180).priority(.defaultHigh), - ]) +// toolbar.translatesAutoresizingMaskIntoConstraints = false +// bottomContainerStackView.addArrangedSubview(toolbar) +// NSLayoutConstraint.activate([ +// toolbar.widthAnchor.constraint(equalToConstant: 180).priority(.defaultHigh), +// ]) avatarView.avatarButton.addTarget(self, action: #selector(MediaInfoDescriptionView.avatarButtonDidPressed(_:)), for: .touchUpInside) @@ -139,7 +139,7 @@ extension MediaInfoDescriptionView { nameMetaLabelTapGestureRecognizer.addTarget(self, action: #selector(MediaInfoDescriptionView.nameMetaLabelDidPressed(_:))) nameMetaLabel.addGestureRecognizer(nameMetaLabelTapGestureRecognizer) - toolbar.delegate = self + //toolbar.delegate = self } } @@ -166,15 +166,15 @@ extension MediaInfoDescriptionView { } // MARK: - StatusToolbarDelegate -extension MediaInfoDescriptionView: StatusToolbarDelegate { - func statusToolbar(_ statusToolbar: StatusToolbar, actionDidPressed action: StatusToolbar.Action, button: UIButton) { - delegate?.mediaInfoDescriptionView(self, statusToolbar: statusToolbar, actionDidPressed: action, button: button) - } - - func statusToolbar(_ statusToolbar: StatusToolbar, menuActionDidPressed action: StatusToolbar.MenuAction, menuButton button: UIButton) { - delegate?.mediaInfoDescriptionView(self, statusToolbar: statusToolbar, menuActionDidPressed: action, menuButton: button) - } -} +//extension MediaInfoDescriptionView: StatusToolbarDelegate { +// func statusToolbar(_ statusToolbar: StatusToolbar, actionDidPressed action: StatusToolbar.Action, button: UIButton) { +// delegate?.mediaInfoDescriptionView(self, statusToolbar: statusToolbar, actionDidPressed: action, button: button) +// } +// +// func statusToolbar(_ statusToolbar: StatusToolbar, menuActionDidPressed action: StatusToolbar.MenuAction, menuButton button: UIButton) { +// delegate?.mediaInfoDescriptionView(self, statusToolbar: statusToolbar, menuActionDidPressed: action, menuButton: button) +// } +//} extension MediaInfoDescriptionView { override var accessibilityElements: [Any]? { @@ -182,7 +182,7 @@ extension MediaInfoDescriptionView { return [ avatarView, nameMetaLabel, - toolbar, + // toolbar, ] } set { } diff --git a/TwidereX/Scene/Notification/NotificationTimeline/NotificationTimelineViewController+DataSourceProvider.swift b/TwidereX/Scene/Notification/NotificationTimeline/NotificationTimelineViewController+DataSourceProvider.swift index aaa2f4ce..faee41e1 100644 --- a/TwidereX/Scene/Notification/NotificationTimeline/NotificationTimelineViewController+DataSourceProvider.swift +++ b/TwidereX/Scene/Notification/NotificationTimeline/NotificationTimelineViewController+DataSourceProvider.swift @@ -28,12 +28,10 @@ extension NotificationTimelineViewController: DataSourceProvider { guard let feed = record.object(in: managedObjectContext) else { return nil } let content = feed.content switch content { - case .twitter(let status): - return .status(.twitter(record: .init(objectID: status.objectID))) - case .mastodon(let status): - return .status(.mastodon(record: .init(objectID: status.objectID))) - case .mastodonNotification(let notification): - return .notification(.mastodon(record: .init(objectID: notification.objectID))) + case .status(let object): + return .status(object.asRecord) + case .notification(let object): + return .notification(object.asRecord) case .none: return nil } diff --git a/TwidereX/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift b/TwidereX/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift index 0a3d61d0..4b58dc47 100644 --- a/TwidereX/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift +++ b/TwidereX/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift @@ -9,7 +9,6 @@ import os.log import UIKit import Combine -import TwidereUI final class NotificationTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { @@ -31,10 +30,6 @@ final class NotificationTimelineViewController: UIViewController, NeedsDependenc private(set) lazy var tableView: UITableView = { let tableView = UITableView() - tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self)) - tableView.register(UserNotificationStyleTableViewCell.self, forCellReuseIdentifier: String(describing: UserNotificationStyleTableViewCell.self)) - tableView.register(TimelineMiddleLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self)) - tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) tableView.backgroundColor = .systemBackground tableView.rowHeight = UITableView.automaticDimension tableView.separatorStyle = .none @@ -120,6 +115,29 @@ extension NotificationTimelineViewController { } } } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + viewModel.viewLayoutFrame.update(view: view) + } + + override func viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + + viewModel.viewLayoutFrame.update(view: view) + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + coordinator.animate {[weak self] _ in + guard let self = self else { return } + self.viewModel.viewLayoutFrame.update(view: self.view) + } completion: { _ in + // do nothing + } + } } extension NotificationTimelineViewController { @@ -169,11 +187,16 @@ extension NotificationTimelineViewController: UITableViewDelegate, AutoGenerateT // check item type inside `loadMore` Task { await viewModel.loadMore(item: item) - } + } // end Task } } +// MARK: - AuthContextProvider +extension NotificationTimelineViewController: AuthContextProvider { + var authContext: AuthContext { viewModel.authContext } +} + // MARK: - StatusViewTableViewCellDelegate extension NotificationTimelineViewController: StatusViewTableViewCellDelegate { } diff --git a/TwidereX/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+Diffable.swift b/TwidereX/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+Diffable.swift index fec95e2e..18360721 100644 --- a/TwidereX/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+Diffable.swift +++ b/TwidereX/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+Diffable.swift @@ -12,7 +12,6 @@ import CoreData import CoreDataStack import TwitterSDK import MastodonSDK -import AppShared extension NotificationTimelineViewModel { @@ -24,19 +23,12 @@ extension NotificationTimelineViewModel { let configuration = NotificationSection.Configuration( statusViewTableViewCellDelegate: statusViewTableViewCellDelegate, userViewTableViewCellDelegate: userViewTableViewCellDelegate, - statusViewConfigurationContext: .init( - dateTimeProvider: DateTimeSwiftProvider(), - twitterTextProvider: OfficialTwitterTextProvider(), - authenticationContext: context.authenticationService.$activeAuthenticationContext - ), - userViewConfigurationContext: .init( - listMembershipViewModel: nil, - authenticationContext: context.authenticationService.activeAuthenticationContext - ) + viewLayoutFramePublisher: $viewLayoutFrame ) diffableDataSource = NotificationSection.diffableDataSource( tableView: tableView, context: context, + authContext: authContext, configuration: configuration ) @@ -134,8 +126,8 @@ extension NotificationTimelineViewModel { // load lastest func loadLatest() async { do { - switch (scope, authenticationContext) { - case (.twitter, .twitter(let authenticationContext)): + switch (scope, authContext.authenticationContext) { + case (.twitter, .twitter(let authenticationContext)): _ = try await context.apiService.twitterMentionTimeline( query: Twitter.API.Statuses.Timeline.TimelineQuery( maxID: nil @@ -164,9 +156,11 @@ extension NotificationTimelineViewModel { } // load timeline gap + @MainActor func loadMore(item: NotificationItem) async { guard case let .feedLoader(record) = item else { return } - guard let authenticationContext = context.authenticationService.activeAuthenticationContext else { return } + + let authenticationContext = authContext.authenticationContext let managedObjectContext = context.managedObjectContext let key = "LoadMore@\(record.objectID)" @@ -183,7 +177,11 @@ extension NotificationTimelineViewModel { // fetch data do { - switch (feed.content, authenticationContext) { + guard case let .notification(object) = feed.content else { + assertionFailure() + throw AppError.implicit(.badRequest) + } + switch (object, authenticationContext) { case (.twitter(let status), .twitter(let authenticationContext)): let query = Twitter.API.Statuses.Timeline.TimelineQuery( count: 20, @@ -194,13 +192,13 @@ extension NotificationTimelineViewModel { authenticationContext: authenticationContext ) - case (.mastodonNotification(let mastodonNotification), .mastodon(let authenticationContext)): + case (.mastodon(let notification), .mastodon(let authenticationContext)): guard case let .mastodon(timelineScope) = scope else { throw AppError.implicit(.badRequest) } _ = try await context.apiService.mastodonNotificationTimeline( query: .init( - maxID: mastodonNotification.id, + maxID: notification.id, types: timelineScope.includeTypes, excludeTypes: timelineScope.excludeTypes ), diff --git a/TwidereX/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+LoadOldestState.swift b/TwidereX/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+LoadOldestState.swift index ec41c79d..c8aa8f0d 100644 --- a/TwidereX/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+LoadOldestState.swift +++ b/TwidereX/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+LoadOldestState.swift @@ -54,11 +54,8 @@ extension NotificationTimelineViewModel.LoadOldestState { super.didEnter(from: previousState) guard let viewModel = viewModel, let stateMachine = stateMachine else { return } - guard let authenticationContext = viewModel.context.authenticationService.activeAuthenticationContext - else { - stateMachine.enter(Fail.self) - return - } + + let authenticationContext = viewModel.authContext.authenticationContext guard let lsatFeedRecord = viewModel.fetchedResultsController.records.last else { stateMachine.enter(Fail.self) @@ -71,7 +68,8 @@ extension NotificationTimelineViewModel.LoadOldestState { let managedObjectContext = viewModel.context.managedObjectContext let _input: NotificationFetchViewModel.Input? = try await managedObjectContext.perform { guard let feed = lsatFeedRecord.object(in: managedObjectContext) else { return nil } - switch (feed.content, authenticationContext) { + guard case let .notification(object) = feed.content else { return nil } + switch (object, authenticationContext) { case (.twitter(let status), .twitter(let authenticationContext)): return NotificationFetchViewModel.Input.twitter(.init( authenticationContext: authenticationContext, @@ -79,7 +77,7 @@ extension NotificationTimelineViewModel.LoadOldestState { count: 20 )) - case (.mastodonNotification(let notification), .mastodon(let authenticationContext)): + case (.mastodon(let notification), .mastodon(let authenticationContext)): guard case let .mastodon(timelineScope) = viewModel.scope else { throw AppError.implicit(.badRequest) } diff --git a/TwidereX/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel.swift b/TwidereX/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel.swift index b902f781..1b5505f3 100644 --- a/TwidereX/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel.swift +++ b/TwidereX/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel.swift @@ -13,7 +13,6 @@ import CoreDataStack import GameplayKit import MastodonSDK import TwidereCore -import TwidereUI final class NotificationTimelineViewModel { @@ -23,11 +22,13 @@ final class NotificationTimelineViewModel { // input let context: AppContext + let authContext: AuthContext let scope: Scope - let authenticationContext: AuthenticationContext let fetchedResultsController: FeedFetchedResultsController let listBatchFetchViewModel = ListBatchFetchViewModel() + @Published public var viewLayoutFrame = ViewLayoutFrame() + @Published var isLoadingLatest = false @Published var lastAutomaticFetchTimestamp: Date? @@ -50,18 +51,18 @@ final class NotificationTimelineViewModel { init( context: AppContext, - scope: Scope, - authenticationContext: AuthenticationContext + authContext: AuthContext, + scope: Scope ) { self.context = context + self.authContext = authContext self.scope = scope - self.authenticationContext = authenticationContext self.fetchedResultsController = FeedFetchedResultsController(managedObjectContext: context.managedObjectContext) // end init let predicate = NotificationTimelineViewModel.feedPredicate( scope: scope, - authenticationContext: authenticationContext + authenticationContext: authContext.authenticationContext ) self.fetchedResultsController.predicate = predicate } diff --git a/TwidereX/Scene/Notification/NotificationViewController.swift b/TwidereX/Scene/Notification/NotificationViewController.swift index 520e984f..9b96b6cd 100644 --- a/TwidereX/Scene/Notification/NotificationViewController.swift +++ b/TwidereX/Scene/Notification/NotificationViewController.swift @@ -21,7 +21,7 @@ final class NotificationViewController: TabmanViewController, NeedsDependency, D weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } var disposeBag = Set() - private(set) lazy var viewModel = NotificationViewModel(context: context, coordinator: coordinator) + var viewModel: NotificationViewModel! private(set) var drawerSidebarTransitionController: DrawerSidebarTransitionController! let avatarBarButtonItem = AvatarBarButtonItem() @@ -51,6 +51,7 @@ final class NotificationViewController: TabmanViewController, NeedsDependency, D } extension NotificationViewController { + override func viewDidLoad() { super.viewDidLoad() @@ -64,6 +65,12 @@ extension NotificationViewController { .receive(on: DispatchQueue.main) .sink { [weak self] needsSetupAvatarBarButtonItem in guard let self = self else { return } + if let leftBarButtonItem = self.navigationItem.leftBarButtonItem, + leftBarButtonItem !== self.avatarBarButtonItem + { + // allow override + return + } self.navigationItem.leftBarButtonItem = needsSetupAvatarBarButtonItem ? self.avatarBarButtonItem : nil } .store(in: &disposeBag) @@ -71,17 +78,14 @@ extension NotificationViewController { avatarBarButtonItem.avatarButton.addTarget(self, action: #selector(NotificationViewController.avatarButtonPressed(_:)), for: .touchUpInside) avatarBarButtonItem.delegate = self - Publishers.CombineLatest( - context.authenticationService.$activeAuthenticationContext, - viewModel.viewDidAppear - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] authenticationContext, _ in - guard let self = self else { return } - let user = authenticationContext?.user(in: self.context.managedObjectContext) - self.avatarBarButtonItem.configure(user: user) - } - .store(in: &disposeBag) + viewModel.viewDidAppear + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + let user = self.viewModel.authContext.authenticationContext.user(in: self.context.managedObjectContext) + self.avatarBarButtonItem.configure(user: user) + } + .store(in: &disposeBag) dataSource = viewModel viewModel.$viewControllers @@ -124,7 +128,7 @@ extension NotificationViewController { // reset notification count Task { - await self.context.notificationService.clearNotificationCountForActiveUser() + await self.context.notificationService.clearNotificationCountForUser(authContext: viewModel.authContext) } // end Task } @@ -134,8 +138,8 @@ extension NotificationViewController { @objc private func avatarButtonPressed(_ sender: UIButton) { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") - let drawerSidebarViewModel = DrawerSidebarViewModel(context: context) - coordinator.present(scene: .drawerSidebar(viewModel: drawerSidebarViewModel), from: self, transition: .custom(transitioningDelegate: drawerSidebarTransitionController)) + let drawerSidebarViewModel = DrawerSidebarViewModel(context: context, authContext: viewModel.authContext) + coordinator.present(scene: .drawerSidebar(viewModel: drawerSidebarViewModel), from: self, transition: .custom(animated: true, transitioningDelegate: drawerSidebarTransitionController)) } } @@ -175,6 +179,11 @@ extension NotificationViewController { } else { pageSegmentedControl.selectedSegmentIndex = 0 } + + pageSegmentedControl.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + pageSegmentedControl.widthAnchor.constraint(greaterThanOrEqualToConstant: 240) + ]) } } @@ -194,3 +203,7 @@ extension NotificationViewController { // MARK: - AvatarBarButtonItemDelegate extension NotificationViewController: AvatarBarButtonItemDelegate { } +// MARK: - AuthContextProvider +extension NotificationViewController: AuthContextProvider { + var authContext: AuthContext { viewModel.authContext } +} diff --git a/TwidereX/Scene/Notification/NotificationViewModel.swift b/TwidereX/Scene/Notification/NotificationViewModel.swift index 6e3e414d..477cf598 100644 --- a/TwidereX/Scene/Notification/NotificationViewModel.swift +++ b/TwidereX/Scene/Notification/NotificationViewModel.swift @@ -17,6 +17,7 @@ final class NotificationViewModel { // input let context: AppContext + let authContext: AuthContext let _coordinator: SceneCoordinator // only use for `setup` @Published var selectedScope: NotificationTimelineViewModel.Scope? = nil @@ -28,18 +29,17 @@ final class NotificationViewModel { @Published var currentPageIndex = 0 @Published var userIdentifier: UserIdentifier? - init(context: AppContext, coordinator: SceneCoordinator) { + init( + context: AppContext, + authContext: AuthContext, + coordinator: SceneCoordinator + ) { self.context = context + self.authContext = authContext self._coordinator = coordinator // end init - context.authenticationService.$activeAuthenticationContext - .receive(on: DispatchQueue.main) - .sink { [weak self] authenticationContext in - guard let self = self else { return } - self.setup(for: authenticationContext) - } - .store(in: &disposeBag) + setup(for: authContext.authenticationContext) } } @@ -70,8 +70,7 @@ extension NotificationViewModel { } let viewControllers = scopes.map { scope in createViewController( - scope: scope, - authenticationContext: authenticationContext + scope: scope ) } @@ -82,16 +81,15 @@ extension NotificationViewModel { } private func createViewController( - scope: NotificationTimelineViewModel.Scope, - authenticationContext: AuthenticationContext + scope: NotificationTimelineViewModel.Scope ) -> UIViewController { let viewController = NotificationTimelineViewController() viewController.context = context viewController.coordinator = _coordinator viewController.viewModel = NotificationTimelineViewModel( context: context, - scope: scope, - authenticationContext: authenticationContext + authContext: authContext, + scope: scope ) return viewController } diff --git a/TwidereX/Scene/Onboarding/Welcome/Mastodon/MastodonAuthenticationController.swift b/TwidereX/Scene/Onboarding/Welcome/Mastodon/MastodonAuthenticationController.swift index 89276fbd..08ba2e66 100644 --- a/TwidereX/Scene/Onboarding/Welcome/Mastodon/MastodonAuthenticationController.swift +++ b/TwidereX/Scene/Onboarding/Welcome/Mastodon/MastodonAuthenticationController.swift @@ -12,9 +12,7 @@ import Combine import CoreDataStack import AuthenticationServices import WebKit -import AppShared import MastodonSDK -import TwidereCommon final class MastodonAuthenticationController: NeedsDependency { diff --git a/TwidereX/Scene/Onboarding/Welcome/Twitter/Option/TwitterAuthenticationOptionViewController.swift b/TwidereX/Scene/Onboarding/Welcome/Twitter/Option/TwitterAuthenticationOptionViewController.swift index 50016c36..1a4b14f4 100644 --- a/TwidereX/Scene/Onboarding/Welcome/Twitter/Option/TwitterAuthenticationOptionViewController.swift +++ b/TwidereX/Scene/Onboarding/Welcome/Twitter/Option/TwitterAuthenticationOptionViewController.swift @@ -9,10 +9,8 @@ import os.log import UIKit import Combine -import AppShared import AuthenticationServices import TwitterSDK -import TwidereUI final class TwitterAuthenticationOptionViewController: UIViewController, NeedsDependency { diff --git a/TwidereX/Scene/Onboarding/Welcome/Twitter/Option/TwitterAuthenticationOptionViewModel.swift b/TwidereX/Scene/Onboarding/Welcome/Twitter/Option/TwitterAuthenticationOptionViewModel.swift index 7aafbafb..436ca72d 100644 --- a/TwidereX/Scene/Onboarding/Welcome/Twitter/Option/TwitterAuthenticationOptionViewModel.swift +++ b/TwidereX/Scene/Onboarding/Welcome/Twitter/Option/TwitterAuthenticationOptionViewModel.swift @@ -8,9 +8,7 @@ import UIKit import Combine -import AppShared import TwitterSDK -import TwidereCommon final class TwitterAuthenticationOptionViewModel: NSObject { 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/Twitter/TwitterAuthenticationController.swift b/TwidereX/Scene/Onboarding/Welcome/Twitter/TwitterAuthenticationController.swift index baade405..49397e73 100644 --- a/TwidereX/Scene/Onboarding/Welcome/Twitter/TwitterAuthenticationController.swift +++ b/TwidereX/Scene/Onboarding/Welcome/Twitter/TwitterAuthenticationController.swift @@ -13,9 +13,6 @@ import CoreDataStack import AuthenticationServices import WebKit import TwitterSDK -import TwidereCommon -import TwidereCore -import AppShared // Note: // use given AuthorizationContext to authorize user diff --git a/TwidereX/Scene/Onboarding/Welcome/WelcomeView.swift b/TwidereX/Scene/Onboarding/Welcome/WelcomeView.swift index f14e738e..d68afab5 100644 --- a/TwidereX/Scene/Onboarding/Welcome/WelcomeView.swift +++ b/TwidereX/Scene/Onboarding/Welcome/WelcomeView.swift @@ -105,6 +105,10 @@ struct WelcomeView: View { } ) .disabled(viewModel.isBusy) + .transaction { transaction in + // disable titile slide in animation + transaction.disablesAnimations = true + } } .padding(.bottom, 20) } diff --git a/TwidereX/Scene/Onboarding/Welcome/WelcomeViewController.swift b/TwidereX/Scene/Onboarding/Welcome/WelcomeViewController.swift index 37dd501c..80d16dc7 100644 --- a/TwidereX/Scene/Onboarding/Welcome/WelcomeViewController.swift +++ b/TwidereX/Scene/Onboarding/Welcome/WelcomeViewController.swift @@ -10,7 +10,6 @@ import os.log import UIKit import SwiftUI import Combine -import AppShared import TwitterSDK import MastodonSDK import AuthenticationServices @@ -64,10 +63,16 @@ extension WelcomeViewController { navigationItem.scrollEdgeAppearance = navigationBarAppearance let hostingController = UIHostingController(rootView: WelcomeView().environmentObject(viewModel)) - hostingController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] - hostingController.view.frame = view.bounds + addChild(hostingController) + hostingController.view.translatesAutoresizingMaskIntoConstraints = false view.addSubview(hostingController.view) - + NSLayoutConstraint.activate([ + hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), + hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + viewModel.delegate = self viewModel.error @@ -75,6 +80,15 @@ extension WelcomeViewController { .sink { [weak self] error in guard let self = self else { return } let alertController = UIAlertController.standardAlert(of: error) + let action = UIAlertAction(title: "Contact", style: .default) { _ in + let url = URL(string: "https://twitter.com/twidereproject")! + self.coordinator.present( + scene: .safari(url: url.absoluteString), + from: nil, + transition: .safariPresent(animated: true, completion: nil) + ) + } + alertController.addAction(action) self.present(alertController, animated: true) } .store(in: &disposeBag) @@ -194,7 +208,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/Onboarding/Welcome/WelcomeViewModel.swift b/TwidereX/Scene/Onboarding/Welcome/WelcomeViewModel.swift index 1d46984a..a1ca8062 100644 --- a/TwidereX/Scene/Onboarding/Welcome/WelcomeViewModel.swift +++ b/TwidereX/Scene/Onboarding/Welcome/WelcomeViewModel.swift @@ -10,10 +10,8 @@ import os.log import Foundation import SwiftUI import Combine -import AppShared import TwitterSDK import MastodonSDK -import TwidereCommon protocol WelcomeViewModelDelegate: AnyObject { func presentTwitterAuthenticationOption() diff --git a/TwidereX/Scene/Profile/FriendshipList/FollowerList/FollowerListViewController.swift b/TwidereX/Scene/Profile/FriendshipList/FollowerList/FollowerListViewController.swift index 5afc97b2..f15fa94e 100644 --- a/TwidereX/Scene/Profile/FriendshipList/FollowerList/FollowerListViewController.swift +++ b/TwidereX/Scene/Profile/FriendshipList/FollowerList/FollowerListViewController.swift @@ -8,10 +8,12 @@ import os.log import UIKit +import SwiftUI import Combine import GameplayKit import TwidereAsset import TwidereLocalization +import TwidereUI final class FollowerListViewController: UIViewController, NeedsDependency { @@ -22,8 +24,8 @@ final class FollowerListViewController: UIViewController, NeedsDependency { var disposeBag = Set() var viewModel: FriendshipListViewModel! - - let emptyStateView = EmptyStateView() + let emptyStateViewModel = EmptyStateView.ViewModel() + let tableView: UITableView = { let tableView = ControlContainableTableView() tableView.backgroundColor = .clear @@ -44,16 +46,6 @@ extension FollowerListViewController { title = L10n.Scene.Followers.title view.backgroundColor = .systemBackground - - emptyStateView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(emptyStateView) - NSLayoutConstraint.activate([ - emptyStateView.topAnchor.constraint(equalTo: view.topAnchor), - emptyStateView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - emptyStateView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - emptyStateView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ]) - emptyStateView.isHidden = true tableView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(tableView) @@ -76,14 +68,24 @@ extension FollowerListViewController { } .store(in: &disposeBag) + let emptyStateViewHostingController = UIHostingController(rootView: EmptyStateView(viewModel: emptyStateViewModel)) + addChild(emptyStateViewHostingController) + emptyStateViewHostingController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(emptyStateViewHostingController.view) + NSLayoutConstraint.activate([ + emptyStateViewHostingController.view.topAnchor.constraint(equalTo: view.topAnchor), + emptyStateViewHostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + emptyStateViewHostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + emptyStateViewHostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + emptyStateViewHostingController.view.isHidden = true + viewModel.$isPermissionDenied .receive(on: DispatchQueue.main) .sink { [weak self] isPermissionDenied in guard let self = self else { return } - self.emptyStateView.iconImageView.image = Asset.Human.eyeSlashLarge.image.withRenderingMode(.alwaysTemplate) - self.emptyStateView.titleLabel.text = L10n.Common.Alerts.PermissionDeniedNotAuthorized.title - self.emptyStateView.messageLabel.text = L10n.Common.Alerts.PermissionDeniedNotAuthorized.message - self.emptyStateView.isHidden = !isPermissionDenied + self.emptyStateViewModel.emptyState = isPermissionDenied ? .unableToAccess() : nil + emptyStateViewHostingController.view.isHidden = !isPermissionDenied } .store(in: &disposeBag) } @@ -97,6 +99,11 @@ extension FollowerListViewController { } +// MARK: - AuthContextProvider +extension FollowerListViewController: AuthContextProvider { + var authContext: AuthContext { viewModel.authContext } +} + // MARK: - UITableViewDelegate extension FollowerListViewController: UITableViewDelegate, AutoGenerateTableViewDelegate { // sourcery:inline:FollowerListViewController.AutoGenerateTableViewDelegate diff --git a/TwidereX/Scene/Profile/FriendshipList/FollowingList/FollowingListViewController.swift b/TwidereX/Scene/Profile/FriendshipList/FollowingList/FollowingListViewController.swift index 96025425..dc9925b3 100644 --- a/TwidereX/Scene/Profile/FriendshipList/FollowingList/FollowingListViewController.swift +++ b/TwidereX/Scene/Profile/FriendshipList/FollowingList/FollowingListViewController.swift @@ -8,11 +8,13 @@ import os.log import UIKit +import SwiftUI import Combine import GameplayKit -import TwidereUI +import TwidereCore import TwidereAsset import TwidereLocalization +import TwidereUI final class FollowingListViewController: UIViewController, NeedsDependency { @@ -23,8 +25,7 @@ final class FollowingListViewController: UIViewController, NeedsDependency { var disposeBag = Set() var viewModel: FriendshipListViewModel! - - let emptyStateView = EmptyStateView() + let emptyStateViewModel = EmptyStateView.ViewModel() let tableView: UITableView = { let tableView = ControlContainableTableView() @@ -48,16 +49,6 @@ extension FollowingListViewController { title = L10n.Scene.Following.title view.backgroundColor = .systemBackground - emptyStateView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(emptyStateView) - NSLayoutConstraint.activate([ - emptyStateView.topAnchor.constraint(equalTo: view.topAnchor), - emptyStateView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - emptyStateView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - emptyStateView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ]) - emptyStateView.isHidden = true - tableView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(tableView) NSLayoutConstraint.activate([ @@ -79,14 +70,24 @@ extension FollowingListViewController { } .store(in: &disposeBag) + let emptyStateViewHostingController = UIHostingController(rootView: EmptyStateView(viewModel: emptyStateViewModel)) + addChild(emptyStateViewHostingController) + emptyStateViewHostingController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(emptyStateViewHostingController.view) + NSLayoutConstraint.activate([ + emptyStateViewHostingController.view.topAnchor.constraint(equalTo: view.topAnchor), + emptyStateViewHostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + emptyStateViewHostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + emptyStateViewHostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + emptyStateViewHostingController.view.isHidden = true + viewModel.$isPermissionDenied .receive(on: DispatchQueue.main) .sink { [weak self] isPermissionDenied in guard let self = self else { return } - self.emptyStateView.iconImageView.image = Asset.Human.eyeSlashLarge.image.withRenderingMode(.alwaysTemplate) - self.emptyStateView.titleLabel.text = L10n.Common.Alerts.PermissionDeniedNotAuthorized.title - self.emptyStateView.messageLabel.text = L10n.Common.Alerts.PermissionDeniedNotAuthorized.message - self.emptyStateView.isHidden = !isPermissionDenied + self.emptyStateViewModel.emptyState = isPermissionDenied ? .unableToAccess() : nil + emptyStateViewHostingController.view.isHidden = !isPermissionDenied } .store(in: &disposeBag) } @@ -100,6 +101,11 @@ extension FollowingListViewController { } +// MARK: - AuthContextProvider +extension FollowingListViewController: AuthContextProvider { + var authContext: AuthContext { viewModel.authContext } +} + // MARK: - UITableViewDelegate extension FollowingListViewController: UITableViewDelegate, AutoGenerateTableViewDelegate { // sourcery:inline:FollowingListViewController.AutoGenerateTableViewDelegate diff --git a/TwidereX/Scene/Profile/FriendshipList/FollowingListViewModel+Diffable.swift b/TwidereX/Scene/Profile/FriendshipList/FollowingListViewModel+Diffable.swift index c9a880b5..6436a673 100644 --- a/TwidereX/Scene/Profile/FriendshipList/FollowingListViewModel+Diffable.swift +++ b/TwidereX/Scene/Profile/FriendshipList/FollowingListViewModel+Diffable.swift @@ -18,16 +18,13 @@ extension FriendshipListViewModel { tableView: UITableView ) { let configuration = UserSection.Configuration( - userViewTableViewCellDelegate: nil, - userViewConfigurationContext: .init( - listMembershipViewModel: nil, - authenticationContext: context.authenticationService.activeAuthenticationContext - ) + userViewTableViewCellDelegate: nil ) diffableDataSource = UserSection.diffableDataSource( tableView: tableView, context: context, + authContext: authContext, configuration: configuration ) @@ -55,7 +52,7 @@ extension FriendshipListViewModel { var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) let newItems: [UserItem] = records.map { - .user(record: $0, style: .friendship) + .user(record: $0, kind: .friend) } snapshot.appendItems(newItems, toSection: .main) return snapshot diff --git a/TwidereX/Scene/Profile/FriendshipList/FollowingListViewModel+State.swift b/TwidereX/Scene/Profile/FriendshipList/FollowingListViewModel+State.swift index 4d00081c..f781b657 100644 --- a/TwidereX/Scene/Profile/FriendshipList/FollowingListViewModel+State.swift +++ b/TwidereX/Scene/Profile/FriendshipList/FollowingListViewModel+State.swift @@ -55,12 +55,8 @@ extension FriendshipListViewModel.State { super.didEnter(from: previousState) guard let viewModel = viewModel, let stateMachine = stateMachine else { return } - guard let authenticationContext = viewModel.context.authenticationService.activeAuthenticationContext - else { - stateMachine.enter(Fail.self) - return - } - + + let authenticationContext = viewModel.authContext.authenticationContext if nextInput == nil { nextInput = { switch (viewModel.userIdentifier, authenticationContext) { diff --git a/TwidereX/Scene/Profile/FriendshipList/FriendshipListViewModel.swift b/TwidereX/Scene/Profile/FriendshipList/FriendshipListViewModel.swift index 531940cd..0e0fe4de 100644 --- a/TwidereX/Scene/Profile/FriendshipList/FriendshipListViewModel.swift +++ b/TwidereX/Scene/Profile/FriendshipList/FriendshipListViewModel.swift @@ -22,6 +22,7 @@ final class FriendshipListViewModel: NSObject { // input let context: AppContext + let authContext: AuthContext let kind: Kind let userIdentifier: UserIdentifier let userRecordFetchedResultController: UserRecordFetchedResultController @@ -46,10 +47,12 @@ final class FriendshipListViewModel: NSObject { init( context: AppContext, + authContext: AuthContext, kind: Kind, userIdentifier: UserIdentifier // identifier for friend list owner user ) { self.context = context + self.authContext = authContext self.kind = kind self.userIdentifier = userIdentifier self.userRecordFetchedResultController = UserRecordFetchedResultController(managedObjectContext: context.managedObjectContext) @@ -61,15 +64,14 @@ final class FriendshipListViewModel: NSObject { // convenience init for current active user convenience init?( context: AppContext, + authContext: AuthContext, kind: Kind ) { - guard let authenticationContext = context.authenticationService.activeAuthenticationContext else { return nil } - let userIdentifier = authenticationContext.userIdentifier - self.init( context: context, + authContext: authContext, kind: kind, - userIdentifier: userIdentifier + userIdentifier: authContext.authenticationContext.userIdentifier ) } diff --git a/TwidereX/Scene/Profile/Header/ProfileHeaderViewController.swift b/TwidereX/Scene/Profile/Header/ProfileHeaderViewController.swift index 24bae4b7..9acad42e 100644 --- a/TwidereX/Scene/Profile/Header/ProfileHeaderViewController.swift +++ b/TwidereX/Scene/Profile/Header/ProfileHeaderViewController.swift @@ -11,6 +11,7 @@ import Combine import TabBarPager import MetaTextKit import MetaTextArea +import MetaLabel import Meta protocol ProfileHeaderViewControllerDelegate: AnyObject { diff --git a/TwidereX/Scene/Profile/Header/View/ProfileFieldCollectionViewCell.swift b/TwidereX/Scene/Profile/Header/View/ProfileFieldCollectionViewCell.swift index a8bc4ac7..3ca3f0b8 100644 --- a/TwidereX/Scene/Profile/Header/View/ProfileFieldCollectionViewCell.swift +++ b/TwidereX/Scene/Profile/Header/View/ProfileFieldCollectionViewCell.swift @@ -8,6 +8,7 @@ import UIKit import MetaTextKit +import MetaLabel import Meta protocol ProfileFieldCollectionViewCellDelegate: AnyObject { diff --git a/TwidereX/Scene/Profile/Header/View/ProfileFieldContentView.swift b/TwidereX/Scene/Profile/Header/View/ProfileFieldContentView.swift index 0263efdf..4f503afe 100644 --- a/TwidereX/Scene/Profile/Header/View/ProfileFieldContentView.swift +++ b/TwidereX/Scene/Profile/Header/View/ProfileFieldContentView.swift @@ -9,6 +9,7 @@ import os.log import UIKit import MetaTextKit +import MetaLabel protocol ProfileFieldContentViewDelegate: AnyObject { func profileFieldContentView(_ contentView: ProfileFieldContentView, metaLabel: MetaLabel, didSelectMeta meta: Meta) @@ -110,7 +111,7 @@ extension ProfileFieldContentView { valueMetaLabel.setContentHuggingPriority(.required - 10, for: .horizontal) valueMetaLabel.setContentCompressionResistancePriority(.required - 10, for: .horizontal) - valueMetaLabel.linkDelegate = self + valueMetaLabel.delegate = self } private func apply(configuration: ContentConfiguration) { @@ -124,7 +125,7 @@ extension ProfileFieldContentView { guard let item = configuration.item else { return } _placeholderMetaLabel.setupAttributes(style: .profileFieldValue) - _placeholderMetaLabel.configure(content: Meta.convert(from: .plaintext(string: " "))) + _placeholderMetaLabel.configure(content: Meta.convert(document: .plaintext(string: " "))) if let symbol = item.symbol { symbolImageView.image = symbol.withRenderingMode(.alwaysTemplate) diff --git a/TwidereX/Scene/Profile/Header/View/ProfileFieldListView.swift b/TwidereX/Scene/Profile/Header/View/ProfileFieldListView.swift index 7354e294..0bbf2852 100644 --- a/TwidereX/Scene/Profile/Header/View/ProfileFieldListView.swift +++ b/TwidereX/Scene/Profile/Header/View/ProfileFieldListView.swift @@ -9,6 +9,7 @@ import os.log import UIKit import MetaTextKit +import MetaLabel import Meta protocol ProfileFieldListViewDelegate: AnyObject { diff --git a/TwidereX/Scene/Profile/Header/View/ProfileHeaderView+ViewModel.swift b/TwidereX/Scene/Profile/Header/View/ProfileHeaderView+ViewModel.swift index c1722da6..972d7711 100644 --- a/TwidereX/Scene/Profile/Header/View/ProfileHeaderView+ViewModel.swift +++ b/TwidereX/Scene/Profile/Header/View/ProfileHeaderView+ViewModel.swift @@ -12,7 +12,6 @@ import CoreDataStack import TwitterMeta import MastodonMeta import AlamofireImage -import AppShared import TwidereCore extension ProfileHeaderView { @@ -38,7 +37,7 @@ extension ProfileHeaderView { @Published var username: String = "" @Published var isFollowsYou: Bool = false - @Published var relationship: Relationship? + @Published var relationship: TwidereCore.Relationship? @Published var bioMetaContent: MetaContent? @Published var fields: [ProfileFieldListView.Item]? @@ -232,7 +231,7 @@ extension ProfileHeaderView { // name user.publisher(for: \.name) .combineLatest(UIContentSizeCategory.publisher) { value, _ in - Meta.convert(from: .plaintext(string: value)) + Meta.convert(document: .plaintext(string: value)) } .assign(to: \.name, on: viewModel) .store(in: &viewModel.configureDisposeBag) @@ -267,10 +266,10 @@ extension ProfileHeaderView { private func configureContent(twitterUser user: TwitterUser) { Publishers.CombineLatest3( user.publisher(for: \.bio), - user.publisher(for: \.bioEntities), + user.publisher(for: \.bioEntitiesTransient), UIContentSizeCategory.publisher ) - .map { _, _, _ in user.bioMetaContent(provider: OfficialTwitterTextProvider()) } + .map { _, _, _ in user.bioMetaContent(provider: SwiftTwitterTextProvider()) } .assign(to: \.bioMetaContent, on: viewModel) .store(in: &viewModel.configureDisposeBag) } @@ -285,7 +284,7 @@ extension ProfileHeaderView { var fields: [ProfileFieldListView.Item] = [] var index = 0 let now = Date() - if let value = user.urlMetaContent(provider: OfficialTwitterTextProvider()) { + if let value = user.urlMetaContent(provider: SwiftTwitterTextProvider()) { let item = ProfileFieldListView.Item( index: index, updateAt: now, @@ -296,7 +295,7 @@ extension ProfileHeaderView { fields.append(item) index += 1 } - if let value = user.locationMetaContent(provider: OfficialTwitterTextProvider()) { + if let value = user.locationMetaContent(provider: SwiftTwitterTextProvider()) { let item = ProfileFieldListView.Item( index: index, updateAt: now, @@ -351,11 +350,12 @@ extension ProfileHeaderView { // name Publishers.CombineLatest3( user.publisher(for: \.displayName), - user.publisher(for: \.emojis), + user.publisher(for: \.emojisTransient), UIContentSizeCategory.publisher ) .map { _, emojis, _ -> MetaContent in - Meta.convert(from: .mastodon(string: user.name, emojis: emojis.asDictionary)) + let content = MastodonContent(content: user.name, emojis: emojis.asDictionary) + return Meta.convert(document: .mastodon(content: content)) } .assign(to: \.name, on: viewModel) .store(in: &viewModel.configureDisposeBag) @@ -383,7 +383,7 @@ extension ProfileHeaderView { private func configureContent(mastodonUser user: MastodonUser) { Publishers.CombineLatest3( user.publisher(for: \.note), - user.publisher(for: \.emojis), + user.publisher(for: \.emojisTransient), UIContentSizeCategory.publisher ) .map { _, _, _ in user.bioMetaContent } @@ -393,8 +393,8 @@ extension ProfileHeaderView { private func configureField(mastodonUser user: MastodonUser) { Publishers.CombineLatest3( - user.publisher(for: \.fields), - user.publisher(for: \.emojis), + user.publisher(for: \.fieldsTransient), + user.publisher(for: \.emojisTransient), UIContentSizeCategory.publisher ) .map { fields, emojis, _ -> [ProfileFieldListView.Item]? in @@ -404,10 +404,10 @@ extension ProfileHeaderView { let emojis = emojis.asDictionary let items = fields.enumerated().map { i, field -> ProfileFieldListView.Item in let key = Meta.convert( - from: .mastodon(string: field.name, emojis: emojis) + document: .mastodon(content: MastodonContent(content: field.name, emojis: emojis)) ) let value = Meta.convert( - from: .mastodon(string: field.value, emojis: emojis) + document: .mastodon(content: MastodonContent(content: field.value, emojis: emojis)) ) return ProfileFieldListView.Item( index: i, diff --git a/TwidereX/Scene/Profile/Header/View/ProfileHeaderView.swift b/TwidereX/Scene/Profile/Header/View/ProfileHeaderView.swift index b5011e77..19db8e54 100644 --- a/TwidereX/Scene/Profile/Header/View/ProfileHeaderView.swift +++ b/TwidereX/Scene/Profile/Header/View/ProfileHeaderView.swift @@ -11,7 +11,7 @@ import UIKit import Combine import MetaTextKit import MetaTextArea -import TwidereUI +import MetaLabel protocol ProfileHeaderViewDelegate: AnyObject { func profileHeaderView(_ headerView: ProfileHeaderView, friendshipButtonPressed button: UIButton) diff --git a/TwidereX/Scene/Profile/LocalProfileViewModel.swift b/TwidereX/Scene/Profile/LocalProfileViewModel.swift index 0a2bf55d..f2ff5892 100644 --- a/TwidereX/Scene/Profile/LocalProfileViewModel.swift +++ b/TwidereX/Scene/Profile/LocalProfileViewModel.swift @@ -10,8 +10,20 @@ import Foundation final class LocalProfileViewModel: ProfileViewModel { - init(context: AppContext, userRecord: UserRecord) { - super.init(context: context) + convenience init( + context: AppContext, + authContext: AuthContext, + userRecord: UserRecord + ) { + self.init( + context: context, + authContext: authContext, + displayLikeTimeline: Self.displayLikeTimeline( + context: context, + authContext: authContext, + userRecord: userRecord + ) + ) setup(user: userRecord) } @@ -34,3 +46,24 @@ final class LocalProfileViewModel: ProfileViewModel { } // end func setup(user:) } + +extension LocalProfileViewModel { + static func displayLikeTimeline( + context: AppContext, + authContext: AuthContext, + userRecord: UserRecord + ) -> Bool { + let managedObjectContext = context.managedObjectContext + let result: Bool = managedObjectContext.performAndWait { + guard let object = userRecord.object(in: managedObjectContext) else { return false } + switch object { + case .twitter: + return true + case .mastodon(let user): + guard case let .mastodon(authenticationContext) = authContext.authenticationContext else { return false } + return user.id == authenticationContext.userID + } + } + return result + } +} diff --git a/TwidereX/Scene/Profile/MeProfileViewModel.swift b/TwidereX/Scene/Profile/MeProfileViewModel.swift index 44a1cf59..78f28d05 100644 --- a/TwidereX/Scene/Profile/MeProfileViewModel.swift +++ b/TwidereX/Scene/Profile/MeProfileViewModel.swift @@ -14,34 +14,18 @@ import TwitterSDK final class MeProfileViewModel: ProfileViewModel { - override init(context: AppContext) { - super.init(context: context) + convenience init( + context: AppContext, + authContext: AuthContext + ) { + self.init( + context: context, + authContext: authContext, + displayLikeTimeline: true + ) + // end init - context.authenticationService.$activeAuthenticationContext - .sink { [weak self] authenticationContext in - guard let self = self else { return } - Task { - await self.setup(authenticationContext: authenticationContext) - } - } - .store(in: &disposeBag) + self.user = authContext.authenticationContext.user(in: context.managedObjectContext) } - - @MainActor - func setup(authenticationContext: AuthenticationContext?) async { - let managedObjectContext = context.managedObjectContext - self.user = await managedObjectContext.perform { - switch authenticationContext { - case .twitter(let authenticationContext): - let authentication = authenticationContext.authenticationRecord.object(in: managedObjectContext) - return authentication.flatMap { .twitter(object: $0.user) } - case .mastodon(let authenticationContext): - let authentication = authenticationContext.authenticationRecord.object(in: managedObjectContext) - return authentication.flatMap { .mastodon(object: $0.user) } - case nil: - return nil - } - } - } - + } diff --git a/TwidereX/Scene/Profile/ProfileViewController.swift b/TwidereX/Scene/Profile/ProfileViewController.swift index 6f5ec431..381c674a 100644 --- a/TwidereX/Scene/Profile/ProfileViewController.swift +++ b/TwidereX/Scene/Profile/ProfileViewController.swift @@ -10,14 +10,13 @@ import UIKit import Combine import CoreData import CoreDataStack -import AppShared import Floaty import Meta -import MetaTextArea import MetaTextKit +import MetaTextArea +import MetaLabel import TabBarPager import XLPagerTabStrip -import TwidereUI final class ProfileViewController: UIViewController, NeedsDependency, DrawerSidebarTransitionHostViewController { @@ -53,48 +52,14 @@ final class ProfileViewController: UIViewController, NeedsDependency, DrawerSide }() private(set) lazy var profilePagingViewController: ProfilePagingViewController = { let profilePagingViewController = ProfilePagingViewController() - - let userTimelineViewModel = UserTimelineViewModel( - context: context, - timelineContext: .init( - timelineKind: .status, - userIdentifier: viewModel.$userIdentifier - ) - ) - userTimelineViewModel.isFloatyButtonDisplay = false - - let userMediaTimelineViewModel = UserMediaTimelineViewModel( - context: context, - timelineContext: .init( - timelineKind: .media, - userIdentifier: viewModel.$userIdentifier - ) - ) - userMediaTimelineViewModel.isFloatyButtonDisplay = false - - let userLikeTimelineViewModel = UserTimelineViewModel( + profilePagingViewController.viewModel = ProfilePagingViewModel( context: context, - timelineContext: .init( - timelineKind: .like, - userIdentifier: viewModel.$userIdentifier - ) + authContext: authContext, + coordinator: coordinator, + displayLikeTimeline: viewModel.displayLikeTimeline, + protected: viewModel.$protected, + userIdentifier: viewModel.$userIdentifier ) - userLikeTimelineViewModel.isFloatyButtonDisplay = false - - profilePagingViewController.viewModel = { - let profilePagingViewModel = ProfilePagingViewModel( - userTimelineViewModel: userTimelineViewModel, - userMediaTimelineViewModel: userMediaTimelineViewModel, - userLikeTimelineViewModel: userLikeTimelineViewModel - ) - profilePagingViewModel.viewControllers.forEach { viewController in - if let viewController = viewController as? NeedsDependency { - viewController.context = context - viewController.coordinator = coordinator - } - } - return profilePagingViewModel - }() return profilePagingViewController }() @@ -139,23 +104,26 @@ extension ProfileViewController { .receive(on: DispatchQueue.main) .sink { [weak self] needsSetupAvatarBarButtonItem in guard let self = self else { return } + if let leftBarButtonItem = self.navigationItem.leftBarButtonItem, + leftBarButtonItem !== self.avatarBarButtonItem + { + // allow override + return + } self.navigationItem.leftBarButtonItem = needsSetupAvatarBarButtonItem ? self.avatarBarButtonItem : nil } .store(in: &disposeBag) avatarBarButtonItem.avatarButton.addTarget(self, action: #selector(ProfileViewController.avatarButtonPressed(_:)), for: .touchUpInside) avatarBarButtonItem.delegate = self - Publishers.CombineLatest( - context.authenticationService.$activeAuthenticationContext, - viewModel.viewDidAppear - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] authenticationContext, _ in - guard let self = self else { return } - let user = authenticationContext?.user(in: self.context.managedObjectContext) - self.avatarBarButtonItem.configure(user: user) - } - .store(in: &disposeBag) + viewModel.viewDidAppear + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + let user = self.viewModel.authContext.authenticationContext.user(in: self.context.managedObjectContext) + self.avatarBarButtonItem.configure(user: user) + } + .store(in: &disposeBag) } addChild(tabBarPagerController) @@ -181,40 +149,20 @@ extension ProfileViewController { tabBarPagerController.delegate = self tabBarPagerController.dataSource = self - Publishers.CombineLatest( - viewModel.$user, - viewModel.$me - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] user, me in - guard let self = self else { return } - guard let user = user, let me = me else { return } - - // set like timeline display - switch (user, me) { - case (.mastodon(let userObject), .mastodon(let meObject)): - self.profilePagingViewController.viewModel.displayLikeTimeline = userObject.objectID == meObject.objectID - default: - self.profilePagingViewController.viewModel.displayLikeTimeline = true - } - } - .store(in: &disposeBag) - Publishers.CombineLatest3( + Publishers.CombineLatest( viewModel.relationshipViewModel.$optionSet, // update trigger - viewModel.$userRecord, - context.authenticationService.$activeAuthenticationContext + viewModel.$userRecord ) .receive(on: DispatchQueue.main) - .sink { [weak self] optionSet, userRecord, authenticationContext in + .sink { [weak self] optionSet, userRecord in guard let self = self else { return } - guard let userRecord = userRecord, - let authenticationContext = authenticationContext - else { + guard let userRecord = userRecord else { self.moreMenuBarButtonItem.menu = nil self.navigationItem.rightBarButtonItems = [] return } + let authenticationContext = self.viewModel.authContext.authenticationContext Task { do { let menu = try await DataSourceFacade.createMenuForUser( @@ -275,8 +223,8 @@ extension ProfileViewController { @objc private func avatarButtonPressed(_ sender: UIButton) { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") - let drawerSidebarViewModel = DrawerSidebarViewModel(context: context) - coordinator.present(scene: .drawerSidebar(viewModel: drawerSidebarViewModel), from: self, transition: .custom(transitioningDelegate: drawerSidebarTransitionController)) + let drawerSidebarViewModel = DrawerSidebarViewModel(context: context, authContext: authContext) + coordinator.present(scene: .drawerSidebar(viewModel: drawerSidebarViewModel), from: self, transition: .custom(animated: true, transitioningDelegate: drawerSidebarTransitionController)) } @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { @@ -300,23 +248,15 @@ extension ProfileViewController { let composeViewModel = ComposeViewModel(context: context) let composeContentViewModel = ComposeContentViewModel( + context: context, + authContext: authContext, kind: { if user == viewModel.me { return .post } else { return .mention(user: user) } - }(), - configurationContext: ComposeContentViewModel.ConfigurationContext( - apiService: context.apiService, - authenticationService: context.authenticationService, - mastodonEmojiService: context.mastodonEmojiService, - statusViewConfigureContext: .init( - dateTimeProvider: DateTimeSwiftProvider(), - twitterTextProvider: OfficialTwitterTextProvider(), - authenticationContext: context.authenticationService.$activeAuthenticationContext - ) - ) + }() ) coordinator.present(scene: .compose(viewModel: composeViewModel, contentViewModel: composeContentViewModel), from: self, transition: .modal(animated: true, completion: nil)) } @@ -331,9 +271,9 @@ extension ProfileViewController: ProfileHeaderViewControllerDelegate { func headerViewController(_ viewController: ProfileHeaderViewController, profileHeaderView: ProfileHeaderView, friendshipButtonDidPressed button: UIButton) { guard let user = viewModel.user else { return } - guard let authenticationContext = context.authenticationService.activeAuthenticationContext else { return } guard let relationshipOptionSet = viewModel.relationshipViewModel.optionSet else { return } let record = UserRecord(object: user) + let authenticationContext = viewModel.authContext.authenticationContext Task { if relationshipOptionSet.contains(.blocking) { @@ -383,7 +323,7 @@ extension ProfileViewController: ProfileHeaderViewControllerDelegate { assertionFailure() return } - let friendshipListViewModel = FriendshipListViewModel(context: context, kind: .following, userIdentifier: userIdentifier) + let friendshipListViewModel = FriendshipListViewModel(context: context, authContext: authContext, kind: .following, userIdentifier: userIdentifier) coordinator.present(scene: .friendshipList(viewModel: friendshipListViewModel), from: self, transition: .show) } @@ -392,7 +332,7 @@ extension ProfileViewController: ProfileHeaderViewControllerDelegate { assertionFailure() return } - let friendshipListViewModel = FriendshipListViewModel(context: context, kind: .follower, userIdentifier: userIdentifier) + let friendshipListViewModel = FriendshipListViewModel(context: context, authContext: authContext, kind: .follower, userIdentifier: userIdentifier) coordinator.present(scene: .friendshipList(viewModel: friendshipListViewModel), from: self, transition: .show) } @@ -405,6 +345,7 @@ extension ProfileViewController: ProfileHeaderViewControllerDelegate { let compositeListViewModel = CompositeListViewModel( context: context, + authContext: authContext, kind: .listed(user) ) coordinator.present( @@ -488,3 +429,8 @@ extension ProfileViewController: ScrollViewContainer { return tabBarPagerController.relayScrollView } } + +// MARK: - AuthContextProvider +extension ProfileViewController: AuthContextProvider { + var authContext: AuthContext { viewModel.authContext } +} diff --git a/TwidereX/Scene/Profile/ProfileViewModel.swift b/TwidereX/Scene/Profile/ProfileViewModel.swift index d62f86de..ffb797e9 100644 --- a/TwidereX/Scene/Profile/ProfileViewModel.swift +++ b/TwidereX/Scene/Profile/ProfileViewModel.swift @@ -21,19 +21,28 @@ class ProfileViewModel: ObservableObject { // input let context: AppContext + let authContext: AuthContext @Published var me: UserObject? @Published var user: UserObject? let viewDidAppear = CurrentValueSubject(Void()) // output + let displayLikeTimeline: Bool @Published var userRecord: UserRecord? @Published var userIdentifier: UserIdentifier? = nil + @Published var protected: Bool? = nil let relationshipViewModel = RelationshipViewModel() // let suspended = CurrentValueSubject(false) - init(context: AppContext) { + init( + context: AppContext, + authContext: AuthContext, + displayLikeTimeline: Bool + ) { self.context = context + self.authContext = authContext + self.displayLikeTimeline = displayLikeTimeline // end init // bind data after publisher setup @@ -44,7 +53,7 @@ class ProfileViewModel: ObservableObject { } $user - .map { user in user.flatMap { UserRecord(object: $0) } } + .map { $0?.asRecord } .assign(to: &$userRecord) $user @@ -59,42 +68,34 @@ class ProfileViewModel: ObservableObject { } } .assign(to: &$userIdentifier) + + $user + .map { $0?.protected } + .assign(to: &$protected) // bind active authentication - context.authenticationService.$activeAuthenticationContext - .sink { [weak self] authenticationContext in - guard let self = self else { return } - Task { - let managedObjectContext = self.context.managedObjectContext - self.me = await managedObjectContext.perform { - switch authenticationContext { - case .twitter(let authenticationContext): - let authentication = authenticationContext.authenticationRecord.object(in: managedObjectContext) - return authentication.flatMap { .twitter(object: $0.user) } - case .mastodon(let authenticationContext): - let authentication = authenticationContext.authenticationRecord.object(in: managedObjectContext) - return authentication.flatMap { .mastodon(object: $0.user) } - case nil: - return nil - } - } + Task { + let managedObjectContext = self.context.managedObjectContext + self.me = await managedObjectContext.perform { + switch authContext.authenticationContext { + case .twitter(let authenticationContext): + let authentication = authenticationContext.authenticationRecord.object(in: managedObjectContext) + return authentication.flatMap { .twitter(object: $0.user) } + case .mastodon(let authenticationContext): + let authentication = authenticationContext.authenticationRecord.object(in: managedObjectContext) + return authentication.flatMap { .mastodon(object: $0.user) } } } - .store(in: &disposeBag) + } // end Task // observe friendship - Publishers.CombineLatest( - $userRecord, - context.authenticationService.$activeAuthenticationContext - ) - .sink { [weak self] userRecord, authenticationContext in - guard let self = self else { return } - guard let userRecord = userRecord, - let authenticationContext = authenticationContext - else { return } - self.dispatchUpdateRelationshipTask(user: userRecord, authenticationContext: authenticationContext) - } - .store(in: &disposeBag) + $userRecord + .sink { [weak self] userRecord in + guard let self = self else { return } + guard let userRecord = userRecord else { return } + self.dispatchUpdateRelationshipTask(user: userRecord, authenticationContext: self.authContext.authenticationContext) + } + .store(in: &disposeBag) } deinit { diff --git a/TwidereX/Scene/Profile/RemoteProfileViewModel.swift b/TwidereX/Scene/Profile/RemoteProfileViewModel.swift index acc2a493..6033842d 100644 --- a/TwidereX/Scene/Profile/RemoteProfileViewModel.swift +++ b/TwidereX/Scene/Profile/RemoteProfileViewModel.swift @@ -14,8 +14,20 @@ import MastodonSDK final class RemoteProfileViewModel: ProfileViewModel { - init(context: AppContext, profileContext: ProfileContext) { - super.init(context: context) + convenience init( + context: AppContext, + authContext: AuthContext, + profileContext: ProfileContext + ) { + self.init( + context: context, + authContext: authContext, + displayLikeTimeline: RemoteProfileViewModel.displayLikeTimeline( + context: context, + authContext: authContext, + profileContext: profileContext + ) + ) configure(profileContext: profileContext) } @@ -26,7 +38,7 @@ final class RemoteProfileViewModel: ProfileViewModel { setup(user: record) case .twitter(let twitterContext): Task { - guard case let .twitter(authenticationContext) = context.authenticationService.activeAuthenticationContext else { return } + guard case let .twitter(authenticationContext) = self.authContext.authenticationContext else { return } do { let _record = try await fetchTwitterUser( twitterContext: twitterContext, @@ -40,7 +52,7 @@ final class RemoteProfileViewModel: ProfileViewModel { } // end Task case .mastodon(let mastodonContext): Task { - guard case let .mastodon(authenticationContext) = context.authenticationService.activeAuthenticationContext else { return } + guard case let .mastodon(authenticationContext) = self.authContext.authenticationContext else { return } do { let _record = try await fetchMastodonUser( mastodonContext: mastodonContext, @@ -101,12 +113,16 @@ extension RemoteProfileViewModel { } extension RemoteProfileViewModel { - func findTwitterUser(userID: TwitterUser.ID) -> ManagedObjectRecord? { - let request = TwitterUser.sortedFetchRequest - request.predicate = TwitterUser.predicate(id: userID) - request.fetchLimit = 1 - guard let user = try? context.managedObjectContext.fetch(request).first else { return nil } - return .init(objectID: user.objectID) + func findTwitterUser(userID: TwitterUser.ID) async -> ManagedObjectRecord? { + let managedObjectContext = context.managedObjectContext + let _record: ManagedObjectRecord? = await managedObjectContext.perform { + let request = TwitterUser.sortedFetchRequest + request.predicate = TwitterUser.predicate(id: userID) + request.fetchLimit = 1 + guard let user = try? managedObjectContext.fetch(request).first else { return nil } + return user.asRecrod + } + return _record } func findMastodonUser(domain: String, userID: MastodonUser.ID) -> ManagedObjectRecord? { @@ -139,7 +155,7 @@ extension RemoteProfileViewModel { } // end switch }() guard let entity = response.value.data?.first else { return nil } - let record = findTwitterUser(userID: entity.id) + let record = await findTwitterUser(userID: entity.id) return record } @@ -162,3 +178,45 @@ extension RemoteProfileViewModel { } } + +extension RemoteProfileViewModel { + static func displayLikeTimeline( + context: AppContext, + authContext: AuthContext, + profileContext: ProfileContext + ) -> Bool { + switch profileContext { + case .record(let record): + let managedObjectContext = context.managedObjectContext + let result: Bool = managedObjectContext.performAndWait { + switch record { + case .twitter(let record): + guard case let .twitter(authenticationContext) = authContext.authenticationContext else { return false } + guard let object = record.object(in: managedObjectContext) else { return false } + return object.id == authenticationContext.userID + case .mastodon(let record): + guard case let .mastodon(authenticationContext) = authContext.authenticationContext else { return false } + guard let object = record.object(in: managedObjectContext) else { return false } + return object.id == authenticationContext.userID + } + } + return result + case .twitter(let twitterContext): + guard case let .twitter(authenticationContext) = authContext.authenticationContext else { return false } + switch twitterContext { + case .userID(let userID): + return userID == authenticationContext.userID + case .username(let username): + let managedObjectContext = context.managedObjectContext + guard let object = authenticationContext.authenticationRecord.object(in: managedObjectContext) else { return false } + return username == object.user.username + } + case .mastodon(let mastodonContext): + guard case let .mastodon(authenticationContext) = authContext.authenticationContext else { return false } + switch mastodonContext { + case .userID(let userID): + return userID == authenticationContext.userID + } + } + } +} diff --git a/TwidereX/Scene/Profile/Segmented/Paging/ProfilePagingViewController.swift b/TwidereX/Scene/Profile/Segmented/Paging/ProfilePagingViewController.swift index d9dda21b..dcf12fd9 100644 --- a/TwidereX/Scene/Profile/Segmented/Paging/ProfilePagingViewController.swift +++ b/TwidereX/Scene/Profile/Segmented/Paging/ProfilePagingViewController.swift @@ -78,15 +78,6 @@ extension ProfilePagingViewController { } super.viewDidLoad() - - viewModel.$displayLikeTimeline - .receive(on: DispatchQueue.main) - .removeDuplicates() - .sink { [weak self] _ in - guard let self = self else { return } - self.reloadPagerTabStripView() - } - .store(in: &disposeBag) } } diff --git a/TwidereX/Scene/Profile/Segmented/Paging/ProfilePagingViewModel.swift b/TwidereX/Scene/Profile/Segmented/Paging/ProfilePagingViewModel.swift index fb3ddd27..8aa1be67 100644 --- a/TwidereX/Scene/Profile/Segmented/Paging/ProfilePagingViewModel.swift +++ b/TwidereX/Scene/Profile/Segmented/Paging/ProfilePagingViewModel.swift @@ -13,30 +13,94 @@ import TabBarPager final class ProfilePagingViewModel: NSObject { // input - @Published var displayLikeTimeline: Bool = true + let context: AppContext + let authContext: AuthContext + let displayLikeTimeline: Bool // output - let homeTimelineViewController = UserTimelineViewController() - let mediaTimelineViewController = UserMediaTimelineViewController() - let likeTimelineViewController = UserLikeTimelineViewController() + let userTimelineViewController: UserTimelineViewController + let mediaTimelineViewController: UserMediaTimelineViewController + let likeTimelineViewController: UserLikeTimelineViewController? init( - userTimelineViewModel: UserTimelineViewModel, - userMediaTimelineViewModel: UserMediaTimelineViewModel, - userLikeTimelineViewModel: UserTimelineViewModel + context: AppContext, + authContext: AuthContext, + coordinator: SceneCoordinator, + displayLikeTimeline: Bool, + protected: Published.Publisher?, + userIdentifier: Published.Publisher? ) { - homeTimelineViewController.viewModel = userTimelineViewModel - mediaTimelineViewController.viewModel = userMediaTimelineViewModel - likeTimelineViewController.viewModel = userLikeTimelineViewModel + self.context = context + self.authContext = authContext + self.displayLikeTimeline = displayLikeTimeline + self.userTimelineViewController = { + let viewController = UserTimelineViewController() + let viewModel = UserTimelineViewModel( + context: context, + authContext: authContext, + timelineContext: .init( + timelineKind: .status, + protected: protected, + userIdentifier: userIdentifier + ) + ) + viewModel.isFloatyButtonDisplay = false + viewController.context = context + viewController.coordinator = coordinator + viewController.viewModel = viewModel + return viewController + }() + self.mediaTimelineViewController = { + let viewController = UserMediaTimelineViewController() + let viewModel = UserMediaTimelineViewModel( + context: context, + authContext: authContext, + timelineContext: .init( + timelineKind: .media, + protected: protected, + userIdentifier: userIdentifier + ) + ) + viewModel.isFloatyButtonDisplay = false + viewController.context = context + viewController.coordinator = coordinator + viewController.viewModel = viewModel + return viewController + }() + self.likeTimelineViewController = { + // switch authContext.authenticationContext { + // case .twitter: return nil + // default: break + // } + let viewController = UserTimelineViewController() + let viewModel = UserTimelineViewModel( + context: context, + authContext: authContext, + timelineContext: .init( + timelineKind: .like, + protected: protected, + userIdentifier: userIdentifier + ) + ) + viewModel.isFloatyButtonDisplay = false + viewController.context = context + viewController.coordinator = coordinator + viewController.viewModel = viewModel + return viewController + }() super.init() + // end init } var viewControllers: [UIViewController & TabBarPage] { - return [ - homeTimelineViewController, + var viewControllers: [UIViewController & TabBarPage] = [ + userTimelineViewController, mediaTimelineViewController, - likeTimelineViewController, ] + if let likeTimelineViewController = likeTimelineViewController { + viewControllers.append(likeTimelineViewController) + } + return viewControllers } deinit { diff --git a/TwidereX/Scene/Root/ContentSplitViewController.swift b/TwidereX/Scene/Root/ContentSplitViewController.swift index cd06d6f5..bdad4f28 100644 --- a/TwidereX/Scene/Root/ContentSplitViewController.swift +++ b/TwidereX/Scene/Root/ContentSplitViewController.swift @@ -9,6 +9,7 @@ import os.log import UIKit import Combine +import TwidereCore final class ContentSplitViewController: UIViewController, NeedsDependency { @@ -20,34 +21,59 @@ final class ContentSplitViewController: UIViewController, NeedsDependency { weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } - + let authContext: AuthContext + private(set) lazy var sidebarViewController: SidebarViewController = { let sidebarViewController = SidebarViewController() sidebarViewController.context = context sidebarViewController.coordinator = coordinator - sidebarViewController.viewModel = SidebarViewModel(context: context) + sidebarViewController.viewModel = SidebarViewModel(context: context, authContext: authContext) sidebarViewController.viewModel.delegate = self return sidebarViewController }() private(set) lazy var mainTabBarController: MainTabBarController = { - let mainTabBarController = MainTabBarController(context: context, coordinator: coordinator) + let mainTabBarController = MainTabBarController(context: context, coordinator: coordinator, authContext: authContext) return mainTabBarController }() + private(set) lazy var secondaryContainerViewController: SecondaryContainerViewController = { + let viewController = SecondaryContainerViewController(context: context, coordinator: coordinator, authContext: authContext) + return viewController + }() + private(set) lazy var secondaryTabBarController: SecondaryTabBarController = { - let secondaryTabBarController = SecondaryTabBarController(context: context, coordinator: coordinator) + let secondaryTabBarController = SecondaryTabBarController(context: context, coordinator: coordinator, authContext: authContext) return secondaryTabBarController }() var mainTabBarViewLeadingLayoutConstraint: NSLayoutConstraint! + var mainTabBarViewTrailingLayoutConstraint: NSLayoutConstraint! + var mainTabBarViewWidthLayoutConstraint: NSLayoutConstraint! @Published var isSidebarDisplay = false @Published var isSecondaryTabBarControllerActive = false + @Published var tabBarTapScrollPreference = UserDefaults.shared.tabBarTapScrollPreference + // [Tab: HashValue] var transformNavigationStackRecord: [TabBarItem: [Int]] = [:] - + + init( + context: AppContext, + coordinator: SceneCoordinator, + authContext: AuthContext + ) { + self.context = context + self.coordinator = coordinator + self.authContext = authContext + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + deinit { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) } @@ -59,6 +85,10 @@ extension ContentSplitViewController { override func viewDidLoad() { super.viewDidLoad() + UserDefaults.shared.publisher(for: \.tabBarTapScrollPreference) + .removeDuplicates() + .assign(to: &$tabBarTapScrollPreference) + navigationController?.setNavigationBarHidden(true, animated: false) view.backgroundColor = .opaqueSeparator @@ -78,14 +108,27 @@ extension ContentSplitViewController { view.addSubview(mainTabBarController.view) mainTabBarController.didMove(toParent: self) mainTabBarViewLeadingLayoutConstraint = mainTabBarController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor) + mainTabBarViewTrailingLayoutConstraint = mainTabBarController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor) + mainTabBarViewWidthLayoutConstraint = mainTabBarController.view.widthAnchor.constraint(equalToConstant: 428).priority(.required - 1) NSLayoutConstraint.activate([ mainTabBarController.view.topAnchor.constraint(equalTo: view.topAnchor), mainTabBarViewLeadingLayoutConstraint, mainTabBarController.view.leadingAnchor.constraint(equalTo: sidebarViewController.view.trailingAnchor, constant: UIView.separatorLineHeight(of: view)).priority(.required - 1), - mainTabBarController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + mainTabBarViewTrailingLayoutConstraint, mainTabBarController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) + addChild(secondaryContainerViewController) + secondaryContainerViewController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(secondaryContainerViewController.view) + secondaryContainerViewController.didMove(toParent: self) + NSLayoutConstraint.activate([ + secondaryContainerViewController.view.topAnchor.constraint(equalTo: view.topAnchor), + secondaryContainerViewController.view.leadingAnchor.constraint(equalTo: mainTabBarController.view.trailingAnchor, constant: UIView.separatorLineHeight(of: view)), // 1pt for divider + secondaryContainerViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + secondaryContainerViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + addChild(secondaryTabBarController) secondaryTabBarController.view.translatesAutoresizingMaskIntoConstraints = false view.addSubview(secondaryTabBarController.view) @@ -129,7 +172,7 @@ extension ContentSplitViewController { extension ContentSplitViewController { func select(tab: TabBarItem) { - sidebarViewController.viewModel.setActiveTab(item: tab) + sidebarViewController.viewModel.tap(item: tab) } } @@ -140,9 +183,28 @@ extension ContentSplitViewController { case .regular: isSidebarDisplay = true mainTabBarViewLeadingLayoutConstraint.isActive = false + let width: CGFloat = { + var minWidth = UIScreen.main.bounds.width + if UIScreen.main.bounds.height < minWidth { + minWidth = UIScreen.main.bounds.height + } + if let window = view.window, window.frame.width < minWidth { + minWidth = window.frame.width + } + return minWidth - ContentSplitViewController.sidebarWidth + }() + let mainWidth = width / 100 * 55 + let secondaryWidth = width / 100 * 45 + secondaryContainerViewController.viewModel.update(width: floor(secondaryWidth)) + mainTabBarViewTrailingLayoutConstraint.isActive = false + mainTabBarViewWidthLayoutConstraint.constant = floor(mainWidth) + mainTabBarViewWidthLayoutConstraint.isActive = true + default: isSidebarDisplay = false mainTabBarViewLeadingLayoutConstraint.isActive = true + mainTabBarViewWidthLayoutConstraint.isActive = false + mainTabBarViewTrailingLayoutConstraint.isActive = true } guard let previousTraitCollection = previousTraitCollection else { return } @@ -205,7 +267,7 @@ extension ContentSplitViewController { for tab in secondaryTabBarController.tabs { guard let secondaryTabBarNavigationController = secondaryTabBarController.navigationController(for: tab) else { continue } if secondaryTabBarNavigationController.viewControllers.count == 1 { - let viewController = tab.viewController(context: context, coordinator: coordinator) + let viewController = tab.viewController(context: context, coordinator: coordinator, authContext: authContext) viewController.navigationItem.hidesBackButton = true secondaryTabBarNavigationController.pushViewController(viewController, animated: false) } @@ -217,15 +279,14 @@ extension ContentSplitViewController { // MARK: - SidebarViewModelDelegate extension ContentSplitViewController: SidebarViewModelDelegate { - func sidebarViewModel(_ viewModel: SidebarViewModel, active tab: TabBarItem) { + func sidebarViewModel(_ viewModel: SidebarViewModel, didTapItem tab: TabBarItem) { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") switch tab { case .settings: - guard let authenticationContext = viewModel.context.authenticationService.activeAuthenticationContext else { return } let settingListViewModel = SettingListViewModel( context: context, - auth: .init(authenticationContext: authenticationContext) + authContext: viewModel.authContext ) coordinator.present( scene: .setting(viewModel: settingListViewModel), @@ -245,5 +306,29 @@ extension ContentSplitViewController: SidebarViewModelDelegate { } } } + + func sidebarViewModel(_ viewModel: SidebarViewModel, didDoubleTapItem tab: TabBarItem) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + + switch tabBarTapScrollPreference { + case .single: return + case .double: break + } + + switch tab { + case .settings: + // do nothing + break + default: + guard viewModel.activeTab == tab else { return } + if mainTabBarController.tabs.contains(tab) { + mainTabBarController.scrollToTop(tab: tab, isMainTabBarControllerActive: !isSecondaryTabBarControllerActive) + } else if secondaryTabBarController.tabs.contains(tab) { + secondaryTabBarController.scrollToTop(tab: tab, isSecondaryTabBarControllerActive: isSecondaryTabBarControllerActive) + } else { + assertionFailure() + } + } + } } diff --git a/TwidereX/Scene/Root/Drawer/DrawerSidebarViewController.swift b/TwidereX/Scene/Root/Drawer/DrawerSidebarViewController.swift index f28b1d51..e4712687 100644 --- a/TwidereX/Scene/Root/Drawer/DrawerSidebarViewController.swift +++ b/TwidereX/Scene/Root/Drawer/DrawerSidebarViewController.swift @@ -10,7 +10,6 @@ import os.log import UIKit import Combine import AlamofireImage -import TwidereUI final class DrawerSidebarViewController: UIViewController, NeedsDependency { @@ -102,15 +101,9 @@ extension DrawerSidebarViewController { settingCollectionView: settingCollectionView ) - context.authenticationService.$activeAuthenticationContext - .sink { [weak self] authenticationContext in - guard let self = self else { return } - let user = authenticationContext?.user(in: self.context.managedObjectContext) - self.headerView.configure(user: user) - } - .store(in: &disposeBag) - headerView.delegate = self + let user = viewModel.authContext.authenticationContext.user(in: self.context.managedObjectContext) + headerView.configure(user: user) } } @@ -123,7 +116,10 @@ extension DrawerSidebarViewController: DrawerSidebarHeaderViewDelegate { _ headerView: DrawerSidebarHeaderView, avatarButtonDidPressed button: UIButton ) { - let profileViewModel = MeProfileViewModel(context: self.context) + let profileViewModel = MeProfileViewModel( + context: context, + authContext: viewModel.authContext // me + ) // present from `presentingViewController` here to reduce transition delay coordinator.present(scene: .profile(viewModel: profileViewModel), from: presentingViewController, transition: .show) @@ -135,7 +131,7 @@ extension DrawerSidebarViewController: DrawerSidebarHeaderViewDelegate { menuButtonDidPressed button: UIButton ) { dismiss(animated: true) { - let accountListViewModel = AccountListViewModel(context: self.context) + let accountListViewModel = AccountListViewModel(context: self.context, authContext: self.viewModel.authContext) self.coordinator.present(scene: .accountList(viewModel: accountListViewModel), from: nil, transition: .modal(animated: true, completion: nil)) } } @@ -152,7 +148,7 @@ extension DrawerSidebarViewController: DrawerSidebarHeaderViewDelegate { profileDashboardView: ProfileDashboardView, followingMeterViewDidPressed meterView: ProfileDashboardMeterView ) { - guard let friendshipListViewModel = FriendshipListViewModel(context: context, kind: .following) else { + guard let friendshipListViewModel = FriendshipListViewModel(context: context, authContext: viewModel.authContext, kind: .following) else { assertionFailure() return } @@ -165,7 +161,7 @@ extension DrawerSidebarViewController: DrawerSidebarHeaderViewDelegate { profileDashboardView: ProfileDashboardView, followersMeterViewDidPressed meterView: ProfileDashboardMeterView ) { - guard let friendshipListViewModel = FriendshipListViewModel(context: context, kind: .follower) else { + guard let friendshipListViewModel = FriendshipListViewModel(context: context, authContext: viewModel.authContext, kind: .follower) else { assertionFailure() return } @@ -178,7 +174,7 @@ extension DrawerSidebarViewController: DrawerSidebarHeaderViewDelegate { profileDashboardView: ProfileDashboardView, listedMeterViewDidPressed meterView: ProfileDashboardMeterView ) { - guard let me = context.authenticationService.activeAuthenticationContext?.user(in: context.managedObjectContext)?.asRecord else { return } + guard let me = viewModel.authContext.authenticationContext.user(in: context.managedObjectContext)?.asRecord else { return } switch me { case .twitter: break case .mastodon: return @@ -186,6 +182,7 @@ extension DrawerSidebarViewController: DrawerSidebarHeaderViewDelegate { let compositeListViewModel = CompositeListViewModel( context: context, + authContext: viewModel.authContext, kind: .listed(me) ) coordinator.present( @@ -207,19 +204,34 @@ extension DrawerSidebarViewController: UICollectionViewDelegate { guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } switch item { case .local: - let federatedTimelineViewModel = FederatedTimelineViewModel(context: context, isLocal: true) + let federatedTimelineViewModel = FederatedTimelineViewModel(context: context, authContext: viewModel.authContext, isLocal: true) coordinator.present(scene: .federatedTimeline(viewModel: federatedTimelineViewModel), from: presentingViewController, transition: .show) case .federated: - let federatedTimelineViewModel = FederatedTimelineViewModel(context: context, isLocal: false) + let federatedTimelineViewModel = FederatedTimelineViewModel(context: context, authContext: viewModel.authContext, isLocal: false) coordinator.present(scene: .federatedTimeline(viewModel: federatedTimelineViewModel), from: presentingViewController, transition: .show) case .likes: - let meLikeTimelineViewModel = MeLikeTimelineViewModel(context: context) - coordinator.present(scene: .userLikeTimeline(viewModel: meLikeTimelineViewModel), from: presentingViewController, transition: .show) + let userLikeTimelineViewModel = UserLikeTimelineViewModel( + context: context, + authContext: viewModel.authContext, + timelineContext: .init( + timelineKind: .like, + protected: { + guard let user = viewModel.authContext.authenticationContext.user(in: context.managedObjectContext) else { return false } + return user.protected + }(), + userIdentifier: viewModel.authContext.authenticationContext.userIdentifier + ) + ) + coordinator.present(scene: .userLikeTimeline(viewModel: userLikeTimelineViewModel), from: presentingViewController, transition: .show) + case .history: + let historyViewModel = HistoryViewModel(context: context, coordinator: coordinator, authContext: viewModel.authContext) + coordinator.present(scene: .history(viewModel: historyViewModel), from: presentingViewController, transition: .show) case .lists: - guard let me = context.authenticationService.activeAuthenticationContext?.user(in: context.managedObjectContext)?.asRecord else { return } + guard let me = viewModel.authContext.authenticationContext.user(in: context.managedObjectContext)?.asRecord else { return } let compositeListViewModel = CompositeListViewModel( context: context, + authContext: viewModel.authContext, kind: .lists(me) ) coordinator.present( @@ -235,11 +247,10 @@ extension DrawerSidebarViewController: UICollectionViewDelegate { guard let diffableDataSource = viewModel.settingDiffableDataSource else { return } guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } guard case .settings = item else { return } - guard let authenticationContext = viewModel.context.authenticationService.activeAuthenticationContext else { return } dismiss(animated: true) { let settingListViewModel = SettingListViewModel( context: self.context, - auth: .init(authenticationContext: authenticationContext) + authContext: self.viewModel.authContext ) self.coordinator.present( scene: .setting(viewModel: settingListViewModel), diff --git a/TwidereX/Scene/Root/Drawer/DrawerSidebarViewModel+Diffable.swift b/TwidereX/Scene/Root/Drawer/DrawerSidebarViewModel+Diffable.swift index 65ca80f7..2735ec1a 100644 --- a/TwidereX/Scene/Root/Drawer/DrawerSidebarViewModel+Diffable.swift +++ b/TwidereX/Scene/Root/Drawer/DrawerSidebarViewModel+Diffable.swift @@ -7,6 +7,7 @@ // import UIKit +import Combine import TwidereAsset extension DrawerSidebarViewModel { @@ -20,18 +21,28 @@ extension DrawerSidebarViewModel { sidebarSnapshot.appendSections([.main]) sidebarDiffableDataSource?.applySnapshotUsingReloadData(sidebarSnapshot) - context.authenticationService.$activeAuthenticationContext - .sink { [weak self] authenticationContext in + UserDefaults.shared.publisher(for: \.preferredEnableHistory).removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] preferredEnableHistory in guard let self = self else { return } + var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) + + let authenticationContext = self.authContext.authenticationContext switch authenticationContext { case .twitter: - snapshot.appendItems([.likes, .lists], toSection: .main) + snapshot.appendItems([.likes], toSection: .main) + if preferredEnableHistory { + snapshot.appendItems([.history], toSection: .main) + } + snapshot.appendItems([.lists], toSection: .main) case .mastodon: - snapshot.appendItems([.local, .federated, .likes, .lists], toSection: .main) - case .none: - break + snapshot.appendItems([.local, .federated, .likes], toSection: .main) + if preferredEnableHistory { + snapshot.appendItems([.history], toSection: .main) + } + snapshot.appendItems([.lists], toSection: .main) } self.sidebarDiffableDataSource?.applySnapshotUsingReloadData(snapshot) } diff --git a/TwidereX/Scene/Root/Drawer/DrawerSidebarViewModel.swift b/TwidereX/Scene/Root/Drawer/DrawerSidebarViewModel.swift index b3ee9cc3..15fbfd6a 100644 --- a/TwidereX/Scene/Root/Drawer/DrawerSidebarViewModel.swift +++ b/TwidereX/Scene/Root/Drawer/DrawerSidebarViewModel.swift @@ -17,13 +17,18 @@ final class DrawerSidebarViewModel { // input let context: AppContext + let authContext: AuthContext // output var sidebarDiffableDataSource: UICollectionViewDiffableDataSource? var settingDiffableDataSource: UICollectionViewDiffableDataSource? - init(context: AppContext) { + init( + context: AppContext, + authContext: AuthContext + ) { self.context = context + self.authContext = authContext // end init } diff --git a/TwidereX/Scene/Root/Drawer/View/DrawerSidebarHeaderView+ViewModel.swift b/TwidereX/Scene/Root/Drawer/View/DrawerSidebarHeaderView+ViewModel.swift index ddd2812a..c2638956 100644 --- a/TwidereX/Scene/Root/Drawer/View/DrawerSidebarHeaderView+ViewModel.swift +++ b/TwidereX/Scene/Root/Drawer/View/DrawerSidebarHeaderView+ViewModel.swift @@ -9,7 +9,6 @@ import UIKit import Combine import MetaTextKit -import TwidereCommon import CoreDataStack import TwidereCore diff --git a/TwidereX/Scene/Root/Drawer/View/DrawerSidebarHeaderView.swift b/TwidereX/Scene/Root/Drawer/View/DrawerSidebarHeaderView.swift index 5d5b0f51..cc29906d 100644 --- a/TwidereX/Scene/Root/Drawer/View/DrawerSidebarHeaderView.swift +++ b/TwidereX/Scene/Root/Drawer/View/DrawerSidebarHeaderView.swift @@ -10,7 +10,7 @@ import os.log import UIKit import TwidereCore import MetaTextKit -import TwidereUI +import MetaLabel protocol DrawerSidebarHeaderViewDelegate: AnyObject { func drawerSidebarHeaderView(_ headerView: DrawerSidebarHeaderView, avatarButtonDidPressed button: UIButton) diff --git a/TwidereX/Scene/Root/MainTab/MainTabBarController.swift b/TwidereX/Scene/Root/MainTab/MainTabBarController.swift index 5c61cf99..ef878254 100644 --- a/TwidereX/Scene/Root/MainTab/MainTabBarController.swift +++ b/TwidereX/Scene/Root/MainTab/MainTabBarController.swift @@ -13,30 +13,60 @@ import Combine import SafariServices import SwiftMessages import TwitterSDK -import TwidereUI -import TwidereCommon +import func QuartzCore.CACurrentMediaTime -final class MainTabBarController: UITabBarController { +final class MainTabBarController: UITabBarController, NeedsDependency { let logger = Logger(subsystem: "MainTabBarController", category: "TabBar") var disposeBag = Set() - weak var context: AppContext! - weak var coordinator: SceneCoordinator! - - @Published var tabs: [TabBarItem] = [ - .home, - .notification, - .search, - .me, - ] - @Published var currentTab: TabBarItem = .home + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + let authContext: AuthContext + + private let doubleTapGestureRecognizer = UITapGestureRecognizer.doubleTapGestureRecognizer + + @Published var tabs: [TabBarItem] + @Published var currentTab: TabBarItem - init(context: AppContext, coordinator: SceneCoordinator) { + static var popToRootAfterActionTolerance: TimeInterval { 0.5 } + var lastPopToRootTime = CACurrentMediaTime() + @Published var tabBarTapScrollPreference = UserDefaults.shared.tabBarTapScrollPreference + + init( + context: AppContext, + coordinator: SceneCoordinator, + authContext: AuthContext + ) { self.context = context self.coordinator = coordinator + self.authContext = authContext + let tabs: [TabBarItem] = { + switch authContext.authenticationContext { + case .twitter: + return [ + .homeList, + // .notification, + .search, + .me, + ] + case .mastodon: + return [ + .home, + .notification, + .search, + .me, + ] + } // end switch + }() + self.tabs = tabs + self.currentTab = tabs.first ?? .me super.init(nibName: nil, bundle: nil) + + UserDefaults.shared.publisher(for: \.tabBarTapScrollPreference) + .removeDuplicates() + .assign(to: &$tabBarTapScrollPreference) } required init?(coder: NSCoder) { @@ -62,26 +92,47 @@ extension MainTabBarController { view.backgroundColor = .systemBackground let viewControllers: [UIViewController] = tabs.map { tab in - let rootViewController = tab.viewController(context: context, coordinator: coordinator) + let rootViewController = tab.viewController(context: context, coordinator: coordinator, authContext: authContext) let viewController = AdaptiveStatusBarStyleNavigationController(rootViewController: rootViewController) viewController.tabBarItem.tag = tab.tag viewController.tabBarItem.title = tab.title viewController.tabBarItem.image = tab.image viewController.tabBarItem.accessibilityLabel = tab.title viewController.tabBarItem.largeContentSizeImage = tab.largeImage - viewController.tabBarItem.imageInsets = UIEdgeInsets(top: 6, left: 0, bottom: -6, right: 0) return viewController } setViewControllers(viewControllers, animated: false) selectedIndex = 0 + // TabBarItem appearance + configureTabBarItemAppearance() + UserDefaults.shared.publisher(for: \.preferredTabBarLabelDisplay) + .receive(on: DispatchQueue.main) + .sink { [weak self] preferredTabBarLabelDisplay in + guard let self = self else { return } + self.configureTabBarItemAppearance() + } + .store(in: &disposeBag) + + // TabBar tap gesture + doubleTapGestureRecognizer.addTarget(self, action: #selector(MainTabBarController.doubleTapGestureRecognizerHandler(_:))) + doubleTapGestureRecognizer.delaysTouchesEnded = false + tabBar.addGestureRecognizer(doubleTapGestureRecognizer) + setupDoubleTapGestureEnabled() + UserDefaults.shared.publisher(for: \.tabBarTapScrollPreference) + .sink { [weak self] _ in + guard let self = self else { return } + self.setupDoubleTapGestureEnabled() + } + .store(in: &disposeBag) + let feedbackGenerator = UINotificationFeedbackGenerator() // post publish result observer context.publisherService.statusPublishResult .receive(on: DispatchQueue.main) .sink { [weak self] result in - guard let _ = self else { return } + guard let self = self else { return } switch result { case .success(let result): var config = SwiftMessages.defaultConfig @@ -108,8 +159,18 @@ extension MainTabBarController { return } + if let error = error as? Twitter.API.Error.ResponseError, case .accountIsTemporarilyLocked = error.twitterAPIError { + Task { @MainActor in + DataSourceFacade.presentForbiddenBanner( + error: error, + dependency: self + ) + } // end Task + return + } + var config = SwiftMessages.defaultConfig - config.duration = .seconds(seconds: 3) + config.duration = .seconds(seconds: 10) config.interactiveHide = true let bannerView = NotificationBannerView() @@ -160,6 +221,27 @@ extension MainTabBarController { return viewController(of: NotificationViewController.self) } + private func configureTabBarItemAppearance() { + let preferredTabBarLabelDisplay = UserDefaults.shared.preferredTabBarLabelDisplay + + for item in tabBar.items ?? [] { + item.imageInsets = preferredTabBarLabelDisplay ? .zero : UIEdgeInsets(top: 6, left: 0, bottom: -6, right: 0) + } + + let tabBarAppearance = ThemeService.setupTabBarAppearance() + tabBar.standardAppearance = tabBarAppearance + tabBar.scrollEdgeAppearance = tabBarAppearance + } + + func setupDoubleTapGestureEnabled() { + doubleTapGestureRecognizer.isEnabled = { + switch UserDefaults.shared.tabBarTapScrollPreference { + case .single: return false + case .double: return true + } + }() + } + private func updateTabBarDisplay() { switch traitCollection.horizontalSizeClass { case .compact: @@ -172,17 +254,16 @@ extension MainTabBarController { @MainActor private func setupNotificationTabIconUpdater() async { // notification tab bar icon updater - await Publishers.CombineLatest3( - context.authenticationService.$activeAuthenticationContext, + await Publishers.CombineLatest( context.notificationService.unreadNotificationCountDidUpdate, // <-- actor property $currentTab ) .receive(on: DispatchQueue.main) - .sink { [weak self] authenticationContext, _, currentTab in + .sink { [weak self] _, currentTab in guard let self = self else { return } - guard let authenticationContext = authenticationContext else { return } guard let notificationViewController = self.notificationViewController else { return } + let authenticationContext = self.authContext.authenticationContext let hasUnreadPushNotification: Bool = { switch authenticationContext { case .twitter: @@ -209,26 +290,78 @@ extension MainTabBarController { extension MainTabBarController { + // A. trigger select by MainTabBarController.tabBarController(_:didSelect:) + // The device is horizontal compact size class + // and user tap on TabBar directly. + // And navigation stack is already pop to root in + // MainTabBarController.tabBarController(_:shouldSelect:) + // B. trigger select by ContentSplitViewController.sidebarViewModel(_:active:) + // The device is horizontal regular size class and user tap on sidebar. + // And there are two conditions (true/false) for `isMainTabBarControllerActive` value. + // Only trigger pop and scroll action when main tab isActive (a.k.a secondary tab bar controller hidden) + // C. trigger select by SceneCoordinator.switchToTabBar(tab:) + // The device idiom is phone. + // Follows B. workflow with default true of `isMainTabBarControllerActive` value + // And maybe force pop to root needs func select(tab: TabBarItem, isMainTabBarControllerActive: Bool = true) { let _index = tabBar.items?.firstIndex(where: { $0.tag == tab.tag }) guard let index = _index else { return } - + defer { selectedIndex = index currentTab = tab } - // check if selected and scroll it to top or pop to top + guard popToRoot(tab: tab, isMainTabBarControllerActive: isMainTabBarControllerActive) else { return } + + // check if preferred double tap for scrollToTop + switch tabBarTapScrollPreference { + case .single: break + case .double: return + } + + scrollToTop(tab: tab, isMainTabBarControllerActive: isMainTabBarControllerActive) + } + + func popToRoot(tab: TabBarItem, isMainTabBarControllerActive: Bool = true) -> Bool { + let _index = tabBar.items?.firstIndex(where: { $0.tag == tab.tag }) + guard let index = _index else { + return false + } + guard isMainTabBarControllerActive, currentTab == tab, let viewController = viewControllers?[safe: index], let navigationController = viewController as? UINavigationController - else { return } + else { return false } guard navigationController.viewControllers.count == 1 else { navigationController.popToRootViewController(animated: true) + lastPopToRootTime = CACurrentMediaTime() + return false + } + + return true + } + + func scrollToTop(tab: TabBarItem, isMainTabBarControllerActive: Bool = true) { + let now = CACurrentMediaTime() + guard now - lastPopToRootTime > MainTabBarController.popToRootAfterActionTolerance else { return } + + let _index = tabBar.items?.firstIndex(where: { $0.tag == tab.tag }) + guard let index = _index else { + return + } + + guard isMainTabBarControllerActive, + currentTab == tab, + let viewController = viewControllers?[safe: index], + let navigationController = viewController as? UINavigationController + else { return } + + guard navigationController.viewControllers.count == 1 else { return } @@ -243,6 +376,18 @@ extension MainTabBarController { extension MainTabBarController { + @objc private func doubleTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + switch sender.state { + case .ended: + guard let scrollViewContainer = selectedViewController?.topMost as? ScrollViewContainer else { return } + scrollViewContainer.scrollToTop(animated: true) + default: + break + } + } + + @objc private func tabBarLongPressGestureRecognizerHandler(_ sender: UILongPressGestureRecognizer) { guard sender.state == .began else { return } @@ -251,7 +396,7 @@ extension MainTabBarController { for item in tabBar.items ?? [] { guard let tab = TabBarItem(rawValue: item.tag) else { continue } guard let view = item.value(forKey: "view") as? UIView else { continue } - guard view.frame.contains(location) else { continue} + guard view.frame.contains(location) else { continue } _tab = tab break @@ -264,7 +409,7 @@ extension MainTabBarController { case .me: let feedbackGenerator = UIImpactFeedbackGenerator(style: .light) feedbackGenerator.impactOccurred() - let accountListViewModel = AccountListViewModel(context: context) + let accountListViewModel = AccountListViewModel(context: context, authContext: authContext) coordinator.present(scene: .accountList(viewModel: accountListViewModel), from: self, transition: .modal(animated: true, completion: nil)) default: break @@ -275,6 +420,23 @@ extension MainTabBarController { // MARK: - UITabBarControllerDelegate extension MainTabBarController: UITabBarControllerDelegate { + + func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + + // fix issue 102: https://github.com/TwidereProject/TwidereX-iOS/issues/102 + // try to pop to root when tap on the same tabBarItem and break select + if tabBarController.selectedViewController === viewController, + let navigationController = viewController as? UINavigationController, + navigationController.viewControllers.count > 1 + { + navigationController.popToRootViewController(animated: true) + return false + } + + return true + } + func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") @@ -285,4 +447,5 @@ extension MainTabBarController: UITabBarControllerDelegate { select(tab: tab) } + } diff --git a/TwidereX/Scene/Root/NewColumn/NewColumnView.swift b/TwidereX/Scene/Root/NewColumn/NewColumnView.swift new file mode 100644 index 00000000..e7195bda --- /dev/null +++ b/TwidereX/Scene/Root/NewColumn/NewColumnView.swift @@ -0,0 +1,58 @@ +// +// NewColumnView.swift +// TwidereX +// +// Created by MainasuK on 2023/5/23. +// Copyright © 2023 Twidere. All rights reserved. +// + +import UIKit +import SwiftUI + +protocol NewColumnViewDelegate: AnyObject { + func newColumnView(_ viewModel: NewColumnViewModel, tabBarItemDidPressed tab: TabBarItem) + func newColumnView(_ viewModel: NewColumnViewModel, source: UINavigationController, openTabMenuAction tab: TabBarItem) +} + +struct NewColumnView: View { + @ObservedObject var viewModel: NewColumnViewModel + + var body: some View { + List { + ForEach(viewModel.tabs, id: \.self) { tab in + Button { + viewModel.delegate?.newColumnView(viewModel, tabBarItemDidPressed: tab) + } label: { + HStack { + Image(uiImage: tab.image) + Text("\(tab.title)") + Spacer() + Image(systemName: "chevron.right") + } // end HStack + .font(.subheadline) + .foregroundColor(Color.primary) + } + .buttonStyle(.borderless) + } // end ForEach + } // end List + .listStyle(.plain) + } // end body +} + +extension NewColumnView { + static func menu( + tabs: [TabBarItem], + viewModel: NewColumnViewModel, + source: UINavigationController + ) -> UIMenu { + let actions: [UIAction] = tabs.map { tab in + UIAction(title: tab.title, image: tab.image) { [weak viewModel, weak source] _ in + guard let source = source, + let viewModel = viewModel + else { return } + viewModel.delegate?.newColumnView(viewModel, source: source, openTabMenuAction: tab) + } + } + return UIMenu(title: "Open Column", options: .displayInline, children: actions) + } +} diff --git a/TwidereX/Scene/Root/NewColumn/NewColumnViewController.swift b/TwidereX/Scene/Root/NewColumn/NewColumnViewController.swift new file mode 100644 index 00000000..7f9074a9 --- /dev/null +++ b/TwidereX/Scene/Root/NewColumn/NewColumnViewController.swift @@ -0,0 +1,56 @@ +// +// NewColumnViewController.swift +// TwidereX +// +// Created by MainasuK on 2023/5/22. +// Copyright © 2023 Twidere. All rights reserved. +// + +import UIKit +import SwiftUI +import TwidereLocalization + +final class NewColumnViewController: UIViewController { + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + let authContext: AuthContext + + public private(set) lazy var viewModel = NewColumnViewModel(context: context, auth: authContext) + private lazy var contentView = NewColumnView(viewModel: viewModel) + + init( + context: AppContext, + coordinator: SceneCoordinator, + authContext: AuthContext + ) { + self.context = context + self.coordinator = coordinator + self.authContext = authContext + super.init(nibName: nil, bundle: nil) + // end init + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension NewColumnViewController { + override func viewDidLoad() { + super.viewDidLoad() + + title = L10n.Scene.Column.title + + let hostingViewController = UIHostingController(rootView: contentView) + hostingViewController.view.translatesAutoresizingMaskIntoConstraints = false + addChild(hostingViewController) + view.addSubview(hostingViewController.view) + hostingViewController.didMove(toParent: self) + NSLayoutConstraint.activate([ + hostingViewController.view.topAnchor.constraint(equalTo: view.topAnchor), + hostingViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + hostingViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + } +} diff --git a/TwidereX/Scene/Root/NewColumn/NewColumnViewModel.swift b/TwidereX/Scene/Root/NewColumn/NewColumnViewModel.swift new file mode 100644 index 00000000..3c41d7ec --- /dev/null +++ b/TwidereX/Scene/Root/NewColumn/NewColumnViewModel.swift @@ -0,0 +1,71 @@ +// +// NewColumnViewModel.swift +// TwidereX +// +// Created by MainasuK on 2023/5/23. +// Copyright © 2023 Twidere. All rights reserved. +// + +import UIKit +import Combine + +final class NewColumnViewModel: ObservableObject { + + weak var delegate: NewColumnViewDelegate? + + // input + let context: AppContext + let auth: AuthContext + + @Published var preferredEnableHistory: Bool = false + + // output + var tabs: [TabBarItem] { + switch auth.authenticationContext { + case .twitter: + var results: [TabBarItem] = [ + // .homeList, + // .notification, + .search, + .me, + .likes, + ] + if preferredEnableHistory { + results.append(.history) + } + results.append(.lists) + return results + case .mastodon: + var results: [TabBarItem] = [ + // .home, + .notification, + .search, + .me, + .local, + .federated, + .likes, + ] + if preferredEnableHistory { + results.append(.history) + } + results.append(.lists) + return results + } // end switch + } + + // output + + init( + context: AppContext, + auth: AuthContext + ) { + self.context = context + self.auth = auth + // end init + + UserDefaults.shared.publisher(for: \.preferredEnableHistory).removeDuplicates() + .receive(on: DispatchQueue.main) + .assign(to: &$preferredEnableHistory) + } + +} diff --git a/TwidereX/Scene/Root/SecondaryContainer/SecondaryContainerViewController.swift b/TwidereX/Scene/Root/SecondaryContainer/SecondaryContainerViewController.swift new file mode 100644 index 00000000..d4f1adb9 --- /dev/null +++ b/TwidereX/Scene/Root/SecondaryContainer/SecondaryContainerViewController.swift @@ -0,0 +1,239 @@ +// +// SecondaryContainerViewController.swift +// TwidereX +// +// Created by MainasuK on 2023/5/22. +// Copyright © 2023 Twidere. All rights reserved. +// + +import UIKit + +final class SecondaryContainerViewController: UIViewController, NeedsDependency { + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + let authContext: AuthContext + + public private(set) lazy var viewModel = SecondaryContainerViewModel(context: context, auth: authContext) + + let containerScrollView = UIScrollView() + let stack = UIStackView() + + init( + context: AppContext, + coordinator: SceneCoordinator, + authContext: AuthContext + ) { + self.context = context + self.coordinator = coordinator + self.authContext = authContext + super.init(nibName: nil, bundle: nil) + // end init + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension SecondaryContainerViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + containerScrollView.frame = view.bounds + containerScrollView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(containerScrollView) + NSLayoutConstraint.activate([ + containerScrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor), + containerScrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor), + containerScrollView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.trailingAnchor), + containerScrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + stack.axis = .horizontal + stack.spacing = UIView.separatorLineHeight(of: view) + stack.alignment = .leading + + stack.translatesAutoresizingMaskIntoConstraints = false + containerScrollView.addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: containerScrollView.contentLayoutGuide.topAnchor), + stack.leadingAnchor.constraint(equalTo: containerScrollView.contentLayoutGuide.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: containerScrollView.contentLayoutGuide.trailingAnchor), + stack.bottomAnchor.constraint(equalTo: containerScrollView.contentLayoutGuide.bottomAnchor), + stack.heightAnchor.constraint(equalTo: containerScrollView.frameLayoutGuide.heightAnchor), + ]) + + setupNewColumn() + } + +} + +extension SecondaryContainerViewController { + private func setupNewColumn() { + let newColumnViewController = NewColumnViewController( + context: context, + coordinator: coordinator, + authContext: authContext + ) + newColumnViewController.viewModel.delegate = self + viewModel.addColumn( + in: stack, + tab: nil, + viewController: newColumnViewController, + setupColumnMenu: false + ) + } +} + +// MARK: - NewColumnViewDelegate +extension SecondaryContainerViewController: NewColumnViewDelegate { + func newColumnView(_ viewModel: NewColumnViewModel, tabBarItemDidPressed tab: TabBarItem) { + guard let viewController = self.viewController(for: tab) else { + assertionFailure() + return + } + + self.viewModel.addColumn( + in: stack, + tab: tab, + viewController: viewController, + newColumnViewModel: viewModel + ) + } + + private func menuActionForOpenTabs(tabs: [TabBarItem], exclude: TabBarItem) -> [TabBarItem] { + var tabs = tabs + tabs.removeAll(where: { $0 == exclude }) + return tabs + } + + private func configure(viewController: NeedsDependency) { + viewController.context = context + viewController.coordinator = coordinator + } + + private func viewController(for tab: TabBarItem) -> UIViewController? { + switch tab { + case .home: + let homeTimelineViewController = HomeTimelineViewController() + configure(viewController: homeTimelineViewController) + homeTimelineViewController.viewModel = HomeTimelineViewModel( + context: context, + authContext: authContext + ) + return homeTimelineViewController + case .homeList: + assertionFailure() + return nil + case .notification: + let notificationViewController = NotificationViewController() + configure(viewController: notificationViewController) + notificationViewController.viewModel = NotificationViewModel( + context: context, + authContext: authContext, + coordinator: coordinator + ) + return notificationViewController + case .search: + let searchViewController = SearchViewController() + configure(viewController: searchViewController) + searchViewController.viewModel = SearchViewModel( + context: context, + authContext: authContext + ) + return searchViewController + case .me: + let profileViewController = ProfileViewController() + configure(viewController: profileViewController) + profileViewController.viewModel = MeProfileViewModel( + context: context, + authContext: authContext + ) + return profileViewController + case .local: + let federatedTimelineViewModel = FederatedTimelineViewModel( + context: context, + authContext: authContext, + isLocal: true + ) + guard let rootViewController = coordinator.get(scene: .federatedTimeline(viewModel: federatedTimelineViewModel)) else { return nil } + return rootViewController + case .federated: + let federatedTimelineViewModel = FederatedTimelineViewModel( + context: context, + authContext: authContext, + isLocal: false + ) + guard let rootViewController = coordinator.get(scene: .federatedTimeline(viewModel: federatedTimelineViewModel)) else { return nil } + return rootViewController + case .messages: + assertionFailure() + return nil + case .likes: + let userLikeTimelineViewModel = UserLikeTimelineViewModel( + context: context, + authContext: authContext, + timelineContext: .init( + timelineKind: .like, + protected: { + guard let user = authContext.authenticationContext.user(in: context.managedObjectContext) else { return false } + return user.protected + }(), + userIdentifier: authContext.authenticationContext.userIdentifier + ) + ) + userLikeTimelineViewModel.isFloatyButtonDisplay = false + guard let rootViewController = coordinator.get(scene: .userLikeTimeline(viewModel: userLikeTimelineViewModel)) else { return nil } + return rootViewController + case .history: + let historyViewModel = HistoryViewModel( + context: context, + coordinator: coordinator, + authContext: authContext + ) + guard let rootViewController = coordinator.get(scene: .history(viewModel: historyViewModel)) else { return nil } + return rootViewController + case .lists: + guard let me = authContext.authenticationContext.user(in: context.managedObjectContext)?.asRecord else { return nil } + + let compositeListViewModel = CompositeListViewModel( + context: context, + authContext: authContext, + kind: .lists(me) + ) + guard let rootViewController = coordinator.get(scene: .compositeList(viewModel: compositeListViewModel)) else { return nil } + return rootViewController + case .trends: + let trendViewModel = TrendViewModel( + context: context, + authContext: authContext + ) + guard let rootViewController = coordinator.get(scene: .trend(viewModel: trendViewModel)) else { return nil } + return rootViewController + case .drafts: + assertionFailure() + return nil + case .settings: + assertionFailure() + return nil + } + } + + func newColumnView( + _ viewModel: NewColumnViewModel, + source: UINavigationController, + openTabMenuAction tab: TabBarItem + ) { + guard let index = self.viewModel.removeColumn(in: stack, navigationController: source) else { return } + guard let viewController = self.viewController(for: tab) else { return } + self.viewModel.addColumn( + in: stack, + at: index, + tab: tab, + viewController: viewController, + newColumnViewModel: viewModel + ) + } +} diff --git a/TwidereX/Scene/Root/SecondaryContainer/SecondaryContainerViewModel.swift b/TwidereX/Scene/Root/SecondaryContainer/SecondaryContainerViewModel.swift new file mode 100644 index 00000000..4ea978ee --- /dev/null +++ b/TwidereX/Scene/Root/SecondaryContainer/SecondaryContainerViewModel.swift @@ -0,0 +1,186 @@ +// +// SecondaryContainerViewModel.swift +// TwidereX +// +// Created by MainasuK on 2023/5/22. +// Copyright © 2023 Twidere. All rights reserved. +// + +import UIKit +import TwidereCommon + +class SecondaryContainerViewModel: ObservableObject { + + // input + let context: AppContext + let auth: AuthContext + + @Published private(set) var width: CGFloat = 375 + + // output + @Published private var viewControllers: [UINavigationController] = [] + + init( + context: AppContext, + auth: AuthContext + ) { + self.context = context + self.auth = auth + // end init + } + +} + +extension SecondaryContainerViewModel { + func addColumn( + in stack: UIStackView, + at index: Int? = nil, + tab: TabBarItem?, + viewController: UIViewController, + setupColumnMenu: Bool = true, + newColumnViewModel: NewColumnViewModel? = nil + ) { + let navigationController = UINavigationController(rootViewController: viewController) + viewControllers.append(navigationController) + + let count = stack.arrangedSubviews.count + if count == 0 { + stack.addArrangedSubview(navigationController.view) + } else { + let at = min(count - 1, index ?? count - 1) + stack.insertArrangedSubview(navigationController.view, at: at) + } + + navigationController.view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + navigationController.view.widthAnchor.constraint(equalToConstant: width).identifier("width"), + navigationController.view.heightAnchor.constraint(equalTo: stack.heightAnchor), + ]) + + if setupColumnMenu { + setupColumnMenuBarButtonItem( + in: stack, + tab: tab, + viewController: viewController, + navigationController: navigationController, + newColumnViewModel: newColumnViewModel + ) + } + } + + func update(width: CGFloat) { + for viewController in viewControllers { + guard let constraint = viewController.view.constraints.first(where: { $0.identifier == "width" }) else { + continue + } + constraint.constant = width + } + + self.width = width + } + + func removeColumn( + in stack: UIStackView, + navigationController: UINavigationController + ) -> Int? { + let _index: Int? = stack.arrangedSubviews.firstIndex(where: { view in + navigationController.view === view + }) + guard let index = _index else { return nil } + + stack.removeArrangedSubview(navigationController.view) + navigationController.view.removeFromSuperview() + navigationController.view.isHidden = true + self.viewControllers.removeAll(where: { $0 === navigationController }) + + return index + } +} + +extension SecondaryContainerViewModel { + private func setupColumnMenuBarButtonItem( + in stack: UIStackView, + tab: TabBarItem?, + viewController: UIViewController, + navigationController: UINavigationController, + newColumnViewModel: NewColumnViewModel? = nil + ) { + let barButtonItem = UIBarButtonItem() + barButtonItem.image = UIImage(systemName: "slider.horizontal.3") + let deferredMenuElement = UIDeferredMenuElement.uncached { [weak self, weak stack, weak viewController, weak navigationController] handler in + guard let self = self, + let stack = stack, + let viewController = viewController, + let navigationController = navigationController + else { + handler([]) + return + } + + var menuElements: [UIMenuElement] = [] + + let closeColumnAction = UIAction(title: L10n.Scene.Column.Actions.closeColumn, image: UIImage(systemName: "xmark.square"), attributes: .destructive) { [weak self, weak stack, weak navigationController] _ in + guard let self = self else { return } + guard let stack = stack else { return } + guard let navigationController = navigationController else { return } + stack.removeArrangedSubview(navigationController.view) + navigationController.view.removeFromSuperview() + navigationController.view.isHidden = true + self.viewControllers.removeAll(where: { $0 === navigationController }) + } + menuElements.append(closeColumnAction) + + let _index: Int? = stack.arrangedSubviews.firstIndex(where: { view in + return navigationController.view === view + }) + if let index = _index { + if index > 0 { + let moveLeftMenuAction = UIAction(title: L10n.Scene.Column.Actions.moveLeft, image: UIImage(systemName: "arrow.left.square")) { [weak self, weak stack, weak navigationController] _ in + guard let self = self else { return } + guard let stack = stack else { return } + guard let navigationController = navigationController else { return } + stack.removeArrangedSubview(navigationController.view) + stack.insertArrangedSubview(navigationController.view, at: index - 1) + } + menuElements.append(moveLeftMenuAction) + } + if index < stack.arrangedSubviews.count - 2 { + let moveRightMenuAction = UIAction(title: L10n.Scene.Column.Actions.moveRight, image: UIImage(systemName: "arrow.right.square")) { [weak self, weak stack, weak navigationController] _ in + guard let self = self else { return } + guard let stack = stack else { return } + guard let navigationController = navigationController else { return } + stack.removeArrangedSubview(navigationController.view) + stack.insertArrangedSubview(navigationController.view, at: index + 1) + } + menuElements.append(moveRightMenuAction) + } + } + + let menu = UIMenu( + title: "", + options: .displayInline, + preferredElementSize: menuElements.count > 1 ? .small : .large, + children: menuElements + ) + handler([menu]) + } + + var children: [UIMenuElement] = [deferredMenuElement] + + if let newColumnViewModel = newColumnViewModel, let tab = tab { + var tabs = newColumnViewModel.tabs + tabs.removeAll(where: { $0 == tab }) + if !tabs.isEmpty { + let openTabsMenu: UIMenu = NewColumnView.menu( + tabs: tabs, + viewModel: newColumnViewModel, + source: navigationController + ) + children.append(openTabsMenu) + } + } + + barButtonItem.menu = UIMenu(title: "", options: .displayInline, children: children) + viewController.navigationItem.leftBarButtonItem = barButtonItem + } +} diff --git a/TwidereX/Scene/Root/SecondaryTab/SecondaryTabBarController.swift b/TwidereX/Scene/Root/SecondaryTab/SecondaryTabBarController.swift index 4294dba0..dc77b2d7 100644 --- a/TwidereX/Scene/Root/SecondaryTab/SecondaryTabBarController.swift +++ b/TwidereX/Scene/Root/SecondaryTab/SecondaryTabBarController.swift @@ -10,6 +10,8 @@ import os.log import Foundation import UIKit import Combine +import TwidereCore +import func QuartzCore.CACurrentMediaTime final class SecondaryTabBarController: UITabBarController { @@ -19,6 +21,7 @@ final class SecondaryTabBarController: UITabBarController { weak var context: AppContext! weak var coordinator: SceneCoordinator! + let authContext: AuthContext @Published var tabs: [TabBarItem] = [] { didSet { @@ -27,11 +30,19 @@ final class SecondaryTabBarController: UITabBarController { } @Published var currentTab: TabBarItem? + static var popToRootAfterActionTolerance: TimeInterval { 0.5 } + var lastPopToRootTime = CACurrentMediaTime() + @Published var tabBarTapScrollPreference = UserDefaults.shared.tabBarTapScrollPreference - init(context: AppContext, coordinator: SceneCoordinator) { + init(context: AppContext, coordinator: SceneCoordinator, authContext: AuthContext) { self.context = context self.coordinator = coordinator + self.authContext = authContext super.init(nibName: nil, bundle: nil) + + UserDefaults.shared.publisher(for: \.tabBarTapScrollPreference) + .removeDuplicates() + .assign(to: &$tabBarTapScrollPreference) } required init?(coder: NSCoder) { @@ -70,20 +81,62 @@ extension SecondaryTabBarController { defer { selectedIndex = index currentTab = tab + } - // check if selected and scroll it to top + guard popToRoot(tab: tab, isSecondaryTabBarControllerActive: isSecondaryTabBarControllerActive) else { return } + + // check if preferred double tap for scrollToTop + switch tabBarTapScrollPreference { + case .single: break + case .double: return + } + + scrollToTop(tab: tab, isSecondaryTabBarControllerActive: isSecondaryTabBarControllerActive) + } + + func popToRoot(tab: TabBarItem, isSecondaryTabBarControllerActive: Bool = true) -> Bool { + let _index = tabBar.items?.firstIndex(where: { $0.tag == tab.tag }) + guard let index = _index else { + return false + } + + // check if selected and pop it to root guard isSecondaryTabBarControllerActive, currentTab == tab, let viewController = viewControllers?[safe: index], let navigationController = viewController as? UINavigationController - else { return } - + else { return false } + // additional prepend SecondaryTabBarRootController guard navigationController.viewControllers.count == 1 + 1 else { if let second = navigationController.viewControllers[safe: 1] { navigationController.popToViewController(second, animated: true) + lastPopToRootTime = CACurrentMediaTime() } + return false + } + + return true + } + + func scrollToTop(tab: TabBarItem, isSecondaryTabBarControllerActive: Bool = true) { + let now = CACurrentMediaTime() + guard now - lastPopToRootTime > SecondaryTabBarController.popToRootAfterActionTolerance else { return } + + let _index = tabBar.items?.firstIndex(where: { $0.tag == tab.tag }) + guard let index = _index else { + return + } + + // check if selected and scroll it to top + guard isSecondaryTabBarControllerActive, + currentTab == tab, + let viewController = viewControllers?[safe: index], + let navigationController = viewController as? UINavigationController + else { return } + + guard navigationController.viewControllers.count == 1 + 1 else { return } @@ -109,7 +162,7 @@ extension SecondaryTabBarController { private func update(tabs: [TabBarItem]) { let viewControllers: [UIViewController] = tabs.map { tab in let viewController = AdaptiveStatusBarStyleNavigationController(rootViewController: SecondaryTabBarRootController()) - let _rootViewController = tab.viewController(context: context, coordinator: coordinator) + let _rootViewController = tab.viewController(context: context, coordinator: coordinator, authContext: authContext) _rootViewController.navigationItem.hidesBackButton = true viewController.pushViewController(_rootViewController, animated: false) viewController.tabBarItem.tag = tab.tag diff --git a/TwidereX/Scene/Root/Sidebar/SidebarView.swift b/TwidereX/Scene/Root/Sidebar/SidebarView.swift index 7671ad36..54af0cdd 100644 --- a/TwidereX/Scene/Root/Sidebar/SidebarView.swift +++ b/TwidereX/Scene/Root/Sidebar/SidebarView.swift @@ -9,7 +9,7 @@ import SwiftUI import TwidereAsset import TwidereLocalization -import TwidereUI +import func QuartzCore.CACurrentMediaTime struct SidebarView: View { @@ -32,7 +32,9 @@ struct SidebarView: View { isActive: viewModel.activeTab == item, useAltStyle: shouldUseAltStyle(for: item) ) { item in - viewModel.setActiveTab(item: item) + viewModel.tap(item: item) + } doubleTapAction: { item in + viewModel.doubleTap(item: item) } } if !viewModel.secondaryTabBarItems.isEmpty { @@ -50,7 +52,9 @@ struct SidebarView: View { isActive: viewModel.activeTab == item, useAltStyle: shouldUseAltStyle(for: item) ) { item in - viewModel.setActiveTab(item: item) + viewModel.tap(item: item) + } doubleTapAction: { item in + viewModel.doubleTap(item: item) } } } @@ -64,7 +68,9 @@ struct SidebarView: View { isActive: false, useAltStyle: false ) { item in - viewModel.setActiveTab(item: item) + viewModel.tap(item: item) + } doubleTapAction: { item in + viewModel.doubleTap(item: item) } } .background(Color(uiColor: .systemBackground)) @@ -77,16 +83,20 @@ struct SidebarView: View { extension SidebarView { struct EntryButton: View { + + @State var lastDoubleTapTime = CACurrentMediaTime() + let item: TabBarItem let isActive: Bool let useAltStyle: Bool - let action: (TabBarItem) -> () + let tapAction: (TabBarItem) -> () + let doubleTapAction: (TabBarItem) -> () var body: some View { let dimension: CGFloat = 32 let padding: CGFloat = 16 Button { - action(item) + // do nothing } label: { VectorImageView( image: useAltStyle ? item.altImage : item.image, @@ -97,6 +107,23 @@ extension SidebarView { .frame(maxWidth: .infinity, alignment: .center) .frame(height: dimension + 2 * padding, alignment: .center) .accessibilityLabel(item.title) + .simultaneousGesture(TapGesture().onEnded { + let now = CACurrentMediaTime() + guard now - lastDoubleTapTime > 0.1 else { + return + } + tapAction(item) + }) + .simultaneousGesture(TapGesture(count: 2).onEnded { + doubleTapAction(item) + lastDoubleTapTime = CACurrentMediaTime() + }) + // note: + // SwiftUI gesture `exclusive(before:)` not works well on macCatalyst. + // So we handle single / double tap gesture simultaneous + // 1. deliver single tap without delay + // 2. deliver double tap if triggered + // 3. cancel second single tap if double tap emitted within 100ms tolerance } } } @@ -104,9 +131,10 @@ extension SidebarView { #if DEBUG struct SidebarView_Previews: PreviewProvider { static var previews: some View { - SidebarView(viewModel: SidebarViewModel(context: .shared)) - .previewLayout(.fixed(width: 80, height: 800)) - + if let authContext = AuthContext.mock(context: .shared) { + SidebarView(viewModel: SidebarViewModel(context: .shared, authContext: authContext)) + .previewLayout(.fixed(width: 80, height: 800)) + } } } #endif diff --git a/TwidereX/Scene/Root/Sidebar/SidebarViewController.swift b/TwidereX/Scene/Root/Sidebar/SidebarViewController.swift index 642aaa35..e3b47305 100644 --- a/TwidereX/Scene/Root/Sidebar/SidebarViewController.swift +++ b/TwidereX/Scene/Root/Sidebar/SidebarViewController.swift @@ -10,7 +10,6 @@ import os.log import UIKit import SwiftUI import Combine -import TwidereUI final class SidebarViewController: UIViewController, NeedsDependency { @@ -81,7 +80,7 @@ extension SidebarViewController { let impactFeedbackGenerator = UIImpactFeedbackGenerator() impactFeedbackGenerator.impactOccurred() - let accountListViewModel = AccountListViewModel(context: context) + let accountListViewModel = AccountListViewModel(context: context, authContext: viewModel.authContext) coordinator.present( scene: .accountList(viewModel: accountListViewModel), from: nil, diff --git a/TwidereX/Scene/Root/Sidebar/SidebarViewModel.swift b/TwidereX/Scene/Root/Sidebar/SidebarViewModel.swift index 5bfa3449..e00cd753 100644 --- a/TwidereX/Scene/Root/Sidebar/SidebarViewModel.swift +++ b/TwidereX/Scene/Root/Sidebar/SidebarViewModel.swift @@ -14,7 +14,8 @@ import TwidereCore import TwidereAsset protocol SidebarViewModelDelegate: AnyObject { - func sidebarViewModel(_ viewModel: SidebarViewModel, active item: TabBarItem) + func sidebarViewModel(_ viewModel: SidebarViewModel, didTapItem item: TabBarItem) + func sidebarViewModel(_ viewModel: SidebarViewModel, didDoubleTapItem item: TabBarItem) } final class SidebarViewModel: ObservableObject { @@ -26,6 +27,7 @@ final class SidebarViewModel: ObservableObject { // input let context: AppContext + let authContext: AuthContext @Published var activeTab: TabBarItem? // output @@ -35,44 +37,57 @@ final class SidebarViewModel: ObservableObject { @Published var hasUnreadPushNotification = false - init(context: AppContext) { + init( + context: AppContext, + authContext: AuthContext + ) { self.context = context + self.authContext = authContext + // end init - context.authenticationService.$activeAuthenticationContext - .sink { [weak self] authenticationContext in + UserDefaults.shared.publisher(for: \.preferredEnableHistory) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] preferredEnableHistory in guard let self = self else { return } - + var items: [TabBarItem] = [] - switch authenticationContext { + switch self.authContext.authenticationContext { case .twitter: - items.append(contentsOf: [.likes, .lists]) + items.append(contentsOf: [.likes]) + if preferredEnableHistory { + items.append(contentsOf: [.history]) + } + items.append(contentsOf: [.lists]) case .mastodon: - items.append(contentsOf: [.local, .federated, .likes, .lists]) - case .none: - break + items.append(contentsOf: [.local, .federated, .likes]) + if preferredEnableHistory { + items.append(contentsOf: [.history]) + } + items.append(contentsOf: [.lists]) } self.secondaryTabBarItems = items - - let user = authenticationContext?.user(in: context.managedObjectContext) - switch user { - case .twitter(let object): - self.avatarURLSubscription = object.publisher(for: \.profileImageURL) - .sink { [weak self] _ in - guard let self = self else { return } - self.avatarURL = object.avatarImageURL() - } - case .mastodon(let object): - self.avatarURLSubscription = object.publisher(for: \.avatar) - .sink { [weak self] _ in - guard let self = self else { return } - self.avatarURL = object.avatar.flatMap { URL(string: $0) } - } - case .none: - self.avatarURL = nil - } } .store(in: &disposeBag) + let user = authContext.authenticationContext.user(in: context.managedObjectContext) + switch user { + case .twitter(let object): + self.avatarURLSubscription = object.publisher(for: \.profileImageURL) + .sink { [weak self] _ in + guard let self = self else { return } + self.avatarURL = object.avatarImageURL() + } + case .mastodon(let object): + self.avatarURLSubscription = object.publisher(for: \.avatar) + .sink { [weak self] _ in + guard let self = self else { return } + self.avatarURL = object.avatar.flatMap { URL(string: $0) } + } + case .none: + self.avatarURL = nil + } + Task { await setupNotificationTabIconUpdater() } // end Task @@ -82,8 +97,12 @@ final class SidebarViewModel: ObservableObject { extension SidebarViewModel { - func setActiveTab(item: TabBarItem) { - delegate?.sidebarViewModel(self, active: item) + func tap(item: TabBarItem) { + delegate?.sidebarViewModel(self, didTapItem: item) + } + + func doubleTap(item: TabBarItem) { + delegate?.sidebarViewModel(self, didDoubleTapItem: item) } } @@ -93,16 +112,14 @@ extension SidebarViewModel { @MainActor private func setupNotificationTabIconUpdater() async { // notification tab bar icon updater - await Publishers.CombineLatest3( - context.authenticationService.$activeAuthenticationContext, + await Publishers.CombineLatest( context.notificationService.unreadNotificationCountDidUpdate, // <-- actor property $activeTab ) .receive(on: DispatchQueue.main) - .sink { [weak self] authenticationContext, _, activeTab in + .sink { [weak self] _, activeTab in guard let self = self else { return } - guard let authenticationContext = authenticationContext else { return } - + let authenticationContext = self.authContext.authenticationContext let hasUnreadPushNotification: Bool = { switch authenticationContext { case .twitter: diff --git a/TwidereX/Scene/Search/SavedSearch/SavedSearchViewController.swift b/TwidereX/Scene/Search/SavedSearch/SavedSearchViewController.swift index a9bad016..f0a4b06a 100644 --- a/TwidereX/Scene/Search/SavedSearch/SavedSearchViewController.swift +++ b/TwidereX/Scene/Search/SavedSearch/SavedSearchViewController.swift @@ -62,6 +62,34 @@ extension SavedSearchViewController { tableView.deselectRow(with: transitionCoordinator, animated: animated) } + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + viewModel.viewLayoutFrame.update(view: view) + } + + override func viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + + viewModel.viewLayoutFrame.update(view: view) + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + coordinator.animate {[weak self] _ in + guard let self = self else { return } + self.viewModel.viewLayoutFrame.update(view: self.view) + } completion: { _ in + // do nothing + } + } + +} + +// MARK: - AuthContextProvider +extension SavedSearchViewController: AuthContextProvider { + var authContext: AuthContext { viewModel.authContext } } // MARK: - UITableViewDelegate @@ -83,10 +111,11 @@ extension SavedSearchViewController: UITableViewDelegate { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") guard let diffableDataSource = self.viewModel.diffableDataSource, - case let .history(record) = diffableDataSource.itemIdentifier(for: indexPath), - let authenticationContext = self.viewModel.context.authenticationService.activeAuthenticationContext + case let .history(record) = diffableDataSource.itemIdentifier(for: indexPath) else { return nil } + let authenticationContext = authContext.authenticationContext + let deleteAction = UIContextualAction( style: .destructive, title: L10n.Common.Controls.Actions.delete, diff --git a/TwidereX/Scene/Search/SavedSearch/SavedSearchViewModel+Diffable.swift b/TwidereX/Scene/Search/SavedSearch/SavedSearchViewModel+Diffable.swift index 98b252c0..dfcdc305 100644 --- a/TwidereX/Scene/Search/SavedSearch/SavedSearchViewModel+Diffable.swift +++ b/TwidereX/Scene/Search/SavedSearch/SavedSearchViewModel+Diffable.swift @@ -16,7 +16,9 @@ extension SavedSearchViewModel { diffableDataSource = SearchSection.diffableDataSource( tableView: tableView, context: context, - configuration: SearchSection.Configuration() + configuration: SearchSection.Configuration( + viewLayoutFramePublisher: $viewLayoutFrame + ) ) var snapshot = NSDiffableDataSourceSnapshot() diff --git a/TwidereX/Scene/Search/SavedSearch/SavedSearchViewModel.swift b/TwidereX/Scene/Search/SavedSearch/SavedSearchViewModel.swift index 696bf352..2252bc6e 100644 --- a/TwidereX/Scene/Search/SavedSearch/SavedSearchViewModel.swift +++ b/TwidereX/Scene/Search/SavedSearch/SavedSearchViewModel.swift @@ -19,22 +19,26 @@ final class SavedSearchViewModel { // input let context: AppContext + let authContext: AuthContext let savedSearchService: SavedSearchService let savedSearchFetchedResultController: SavedSearchFetchedResultController + + @Published public var viewLayoutFrame = ViewLayoutFrame() // output var diffableDataSource: UITableViewDiffableDataSource? @Published var isSavedSearchFetched = false - init(context: AppContext) { + init( + context: AppContext, + authContext: AuthContext + ) { self.context = context + self.authContext = authContext self.savedSearchService = SavedSearchService(apiService: context.apiService) self.savedSearchFetchedResultController = SavedSearchFetchedResultController(managedObjectContext: context.managedObjectContext) // end init - context.authenticationService.$activeAuthenticationContext - .map { $0?.userIdentifier } - .assign(to: \.userIdentifier, on: savedSearchFetchedResultController) - .store(in: &disposeBag) + savedSearchFetchedResultController.userIdentifier = authContext.authenticationContext.userIdentifier } } diff --git a/TwidereX/Scene/Search/Search/Cell/TrendTableViewCell.swift b/TwidereX/Scene/Search/Search/Cell/TrendTableViewCell.swift index 12ddd460..988919cb 100644 --- a/TwidereX/Scene/Search/Search/Cell/TrendTableViewCell.swift +++ b/TwidereX/Scene/Search/Search/Cell/TrendTableViewCell.swift @@ -8,39 +8,41 @@ import UIKit import MetaTextKit +import MetaLabel import TwidereCore final class TrendTableViewCell: UITableViewCell { - let container: UIStackView = { - let stackView = UIStackView() - stackView.axis = .horizontal - stackView.spacing = 16 - return stackView - }() - - let infoContainer: UIStackView = { - let stackView = UIStackView() - stackView.axis = .vertical - return stackView - }() - - let lineChartContainer: UIStackView = { - let stackView = UIStackView() - stackView.axis = .vertical - return stackView - }() - - let primaryLabel = MetaLabel(style: .searchTrendTitle) - let secondaryLabel = PlainLabel(style: .searchTrendSubtitle) - let supplementaryLabel = PlainLabel(style: .searchTrendCount) - let lineChartView = LineChartView() +// let container: UIStackView = { +// let stackView = UIStackView() +// stackView.axis = .horizontal +// stackView.spacing = 16 +// return stackView +// }() +// +// let infoContainer: UIStackView = { +// let stackView = UIStackView() +// stackView.axis = .vertical +// return stackView +// }() +// +// let lineChartContainer: UIStackView = { +// let stackView = UIStackView() +// stackView.axis = .vertical +// return stackView +// }() +// +// let primaryLabel = MetaLabel(style: .searchTrendTitle) +// let secondaryLabel = PlainLabel(style: .searchTrendSubtitle) +// let supplementaryLabel = PlainLabel(style: .searchTrendCount) +// let lineChartView = LineChartView() override func prepareForReuse() { super.prepareForReuse() - accessoryType = .none - resetDisplay() + contentConfiguration = nil +// accessoryType = .none +// resetDisplay() } override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { @@ -58,72 +60,72 @@ final class TrendTableViewCell: UITableViewCell { extension TrendTableViewCell { private func _init() { - container.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(container) - NSLayoutConstraint.activate([ - container.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 11), - container.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), - container.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), - contentView.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: 11), - ]) - - // container: H - [ info container | padding | supplementary | line chart container ] - container.addArrangedSubview(infoContainer) - - // info container: V - [ primary | secondary ] - infoContainer.addArrangedSubview(primaryLabel) - infoContainer.addArrangedSubview(secondaryLabel) - - // padding - let padding = UIView() - container.addArrangedSubview(padding) - - // supplementary - container.addArrangedSubview(supplementaryLabel) - supplementaryLabel.setContentHuggingPriority(.required - 1, for: .horizontal) - - // line chart - container.addArrangedSubview(lineChartContainer) - - let lineChartViewTopPadding = UIView() - let lineChartViewBottomPadding = UIView() - lineChartViewTopPadding.translatesAutoresizingMaskIntoConstraints = false - lineChartViewBottomPadding.translatesAutoresizingMaskIntoConstraints = false - lineChartView.translatesAutoresizingMaskIntoConstraints = false - lineChartContainer.addArrangedSubview(lineChartViewTopPadding) - lineChartContainer.addArrangedSubview(lineChartView) - lineChartContainer.addArrangedSubview(lineChartViewBottomPadding) - NSLayoutConstraint.activate([ - lineChartView.widthAnchor.constraint(equalToConstant: 66), - lineChartView.heightAnchor.constraint(equalToConstant: 27), - lineChartViewTopPadding.heightAnchor.constraint(equalTo: lineChartViewBottomPadding.heightAnchor), - ]) - - primaryLabel.isUserInteractionEnabled = false - - resetDisplay() +// container.translatesAutoresizingMaskIntoConstraints = false +// contentView.addSubview(container) +// NSLayoutConstraint.activate([ +// container.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 11), +// container.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), +// container.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), +// contentView.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: 11), +// ]) +// +// // container: H - [ info container | padding | supplementary | line chart container ] +// container.addArrangedSubview(infoContainer) +// +// // info container: V - [ primary | secondary ] +// infoContainer.addArrangedSubview(primaryLabel) +// infoContainer.addArrangedSubview(secondaryLabel) +// +// // padding +// let padding = UIView() +// container.addArrangedSubview(padding) +// +// // supplementary +// container.addArrangedSubview(supplementaryLabel) +// supplementaryLabel.setContentHuggingPriority(.required - 1, for: .horizontal) +// +// // line chart +// container.addArrangedSubview(lineChartContainer) +// +// let lineChartViewTopPadding = UIView() +// let lineChartViewBottomPadding = UIView() +// lineChartViewTopPadding.translatesAutoresizingMaskIntoConstraints = false +// lineChartViewBottomPadding.translatesAutoresizingMaskIntoConstraints = false +// lineChartView.translatesAutoresizingMaskIntoConstraints = false +// lineChartContainer.addArrangedSubview(lineChartViewTopPadding) +// lineChartContainer.addArrangedSubview(lineChartView) +// lineChartContainer.addArrangedSubview(lineChartViewBottomPadding) +// NSLayoutConstraint.activate([ +// lineChartView.widthAnchor.constraint(equalToConstant: 66), +// lineChartView.heightAnchor.constraint(equalToConstant: 27), +// lineChartViewTopPadding.heightAnchor.constraint(equalTo: lineChartViewBottomPadding.heightAnchor), +// ]) +// +// primaryLabel.isUserInteractionEnabled = false +// +// resetDisplay() } } -extension TrendTableViewCell { - - func resetDisplay() { - secondaryLabel.isHidden = true - supplementaryLabel.isHidden = true - lineChartContainer.isHidden = true - } - - func setSecondaryLabelDisplay() { - secondaryLabel.isHidden = false - } - - func setSupplementaryLabelDisplay() { - supplementaryLabel.isHidden = false - } - - func setLineChartViewDisplay() { - lineChartContainer.isHidden = false - } - -} +//extension TrendTableViewCell { +// +// func resetDisplay() { +// secondaryLabel.isHidden = true +// supplementaryLabel.isHidden = true +// lineChartContainer.isHidden = true +// } +// +// func setSecondaryLabelDisplay() { +// secondaryLabel.isHidden = false +// } +// +// func setSupplementaryLabelDisplay() { +// supplementaryLabel.isHidden = false +// } +// +// func setLineChartViewDisplay() { +// lineChartContainer.isHidden = false +// } +// +//} diff --git a/TwidereX/Scene/Search/Search/SearchViewController.swift b/TwidereX/Scene/Search/Search/SearchViewController.swift index 2a4165a5..30c52b4a 100644 --- a/TwidereX/Scene/Search/Search/SearchViewController.swift +++ b/TwidereX/Scene/Search/Search/SearchViewController.swift @@ -10,7 +10,6 @@ import os.log import UIKit import Combine import TwidereLocalization -import TwidereUI // DrawerSidebarTransitionableViewController final class SearchViewController: UIViewController, NeedsDependency, DrawerSidebarTransitionHostViewController { @@ -23,7 +22,7 @@ final class SearchViewController: UIViewController, NeedsDependency, DrawerSideb var disposeBag = Set() var viewModel: SearchViewModel! - private(set) lazy var searchResultViewModel = SearchResultViewModel(context: context, coordinator: coordinator) + private(set) lazy var searchResultViewModel = SearchResultViewModel(context: context, authContext: authContext, coordinator: coordinator) private(set) lazy var searchResultViewController: SearchResultViewController = { let searchResultViewController = SearchResultViewController() searchResultViewController.context = context @@ -126,27 +125,20 @@ extension SearchViewController { .store(in: &disposeBag) // bind twitter trend place entry - viewModel.context.authenticationService.$activeAuthenticationContext - .receive(on: DispatchQueue.main) - .sink { [weak self] authenticationContext in - guard let self = self else { return } - switch authenticationContext { - case .twitter: - self.trendSectionHeaderView.button.isHidden = false - default: - self.trendSectionHeaderView.button.isHidden = true - } - } - .store(in: &disposeBag) + switch viewModel.authContext.authenticationContext { + case .twitter: + self.trendSectionHeaderView.button.isHidden = false + default: + self.trendSectionHeaderView.button.isHidden = true + } // bind searchBar bookmark - Publishers.CombineLatest3( + Publishers.CombineLatest( viewModel.$savedSearchTexts, - searchResultViewModel.$searchText, - context.authenticationService.$activeAuthenticationContext + searchResultViewModel.$searchText ) .receive(on: DispatchQueue.main) - .sink { [weak self] texts, searchText, activeAuthenticationContext in + .sink { [weak self] texts, searchText in guard let self = self else { return } let text = searchText.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty, @@ -155,12 +147,8 @@ extension SearchViewController { self.searchController.searchBar.showsBookmarkButton = false return } - switch activeAuthenticationContext { - case .twitter, .mastodon: - self.searchController.searchBar.showsBookmarkButton = true - case nil: - self.searchController.searchBar.showsBookmarkButton = false - } + + self.searchController.searchBar.showsBookmarkButton = true } .store(in: &disposeBag) } @@ -179,6 +167,29 @@ extension SearchViewController { viewModel.viewDidAppear.send() } + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + viewModel.viewLayoutFrame.update(view: view) + } + + override func viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + + viewModel.viewLayoutFrame.update(view: view) + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + coordinator.animate {[weak self] _ in + guard let self = self else { return } + self.viewModel.viewLayoutFrame.update(view: self.view) + } completion: { _ in + // do nothing + } + } + } extension SearchViewController { @@ -235,7 +246,7 @@ extension SearchViewController: UITableViewDelegate { case .twitter(let trend): self.searchText(trend.name) case .mastodon(let tag): - let hashtagTimelineViewModel = HashtagTimelineViewModel(context: context, hashtag: tag.name) + let hashtagTimelineViewModel = HashtagTimelineViewModel(context: context, authContext: authContext, hashtag: tag.name) coordinator.present( scene: .hashtagTimeline(viewModel: hashtagTimelineViewModel), from: self, @@ -268,10 +279,11 @@ extension SearchViewController: UITableViewDelegate { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") guard let diffableDataSource = self.viewModel.diffableDataSource, - case let .history(record) = diffableDataSource.itemIdentifier(for: indexPath), - let authenticationContext = self.viewModel.context.authenticationService.activeAuthenticationContext + case let .history(record) = diffableDataSource.itemIdentifier(for: indexPath) else { return nil } + let authenticationContext = viewModel.authContext.authenticationContext + let deleteAction = UIContextualAction( style: .destructive, title: L10n.Common.Controls.Actions.delete, @@ -313,3 +325,8 @@ extension SearchViewController { } } + +// MARK: - AuthContextProvider +extension SearchViewController: AuthContextProvider { + var authContext: AuthContext { viewModel.authContext } +} diff --git a/TwidereX/Scene/Search/Search/SearchViewModel+Diffable.swift b/TwidereX/Scene/Search/Search/SearchViewModel+Diffable.swift index 54996b05..1774973f 100644 --- a/TwidereX/Scene/Search/Search/SearchViewModel+Diffable.swift +++ b/TwidereX/Scene/Search/Search/SearchViewModel+Diffable.swift @@ -17,7 +17,9 @@ extension SearchViewModel { diffableDataSource = SearchSection.diffableDataSource( tableView: tableView, context: context, - configuration: SearchSection.Configuration() + configuration: SearchSection.Configuration( + viewLayoutFramePublisher: $viewLayoutFrame + ) ) var snapshot = NSDiffableDataSourceSnapshot() @@ -79,22 +81,15 @@ extension SearchViewModel { } .eraseToAnyPublisher() - Publishers.CombineLatest3( + Publishers.CombineLatest( historyItems, - trendItems, - context.authenticationService.$activeAuthenticationContext + trendItems ) .throttle(for: 0.3, scheduler: DispatchQueue.main, latest: true) - .sink { [weak self] historyItems, trendItems, authenticationContext in + .sink { [weak self] historyItems, trendItems in guard let self = self else { return } guard let diffableDataSource = self.diffableDataSource else { return } - - guard let authenticationContext = authenticationContext else { - let snapshot = NSDiffableDataSourceSnapshot() - diffableDataSource.applySnapshotUsingReloadData(snapshot) - return - } - + var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.history, .trend]) diff --git a/TwidereX/Scene/Search/Search/SearchViewModel.swift b/TwidereX/Scene/Search/Search/SearchViewModel.swift index 82f3f902..2233dbcf 100644 --- a/TwidereX/Scene/Search/Search/SearchViewModel.swift +++ b/TwidereX/Scene/Search/Search/SearchViewModel.swift @@ -21,24 +21,31 @@ final class SearchViewModel { // input let context: AppContext + let authContext: AuthContext let savedSearchViewModel: SavedSearchViewModel let trendViewModel: TrendViewModel let viewDidAppear = PassthroughSubject() + @Published public var viewLayoutFrame = ViewLayoutFrame() + // output var diffableDataSource: UITableViewDiffableDataSource? @Published var savedSearchTexts = Set() - init(context: AppContext) { + init( + context: AppContext, + authContext: AuthContext + ) { self.context = context - self.savedSearchViewModel = SavedSearchViewModel(context: context) - self.trendViewModel = TrendViewModel(context: context) + self.authContext = authContext + self.savedSearchViewModel = SavedSearchViewModel(context: context, authContext: authContext) + self.trendViewModel = TrendViewModel(context: context, authContext: authContext) // end init viewDidAppear .sink { [weak self] _ in guard let self = self else { return } - guard let authenticationContext = self.context.authenticationService.activeAuthenticationContext else { return } + let authenticationContext = self.authContext.authenticationContext Task { do { @@ -57,7 +64,7 @@ final class SearchViewModel { ) .sink { [weak self] trendGroupIndex, _ in guard let self = self else { return } - guard let authenticationContext = self.context.authenticationService.activeAuthenticationContext else { return } + let authenticationContext = self.authContext.authenticationContext Task { @MainActor in do { diff --git a/TwidereX/Scene/Search/SearchResult/Hashtag/SearchHashtagViewController.swift b/TwidereX/Scene/Search/SearchResult/Hashtag/SearchHashtagViewController.swift index f4fe0b21..6ced7f76 100644 --- a/TwidereX/Scene/Search/SearchResult/Hashtag/SearchHashtagViewController.swift +++ b/TwidereX/Scene/Search/SearchResult/Hashtag/SearchHashtagViewController.swift @@ -88,6 +88,11 @@ extension SearchHashtagViewController: DeselectRowTransitionCoordinator { } } +// MARK: - AuthContextProvider +extension SearchHashtagViewController: AuthContextProvider { + var authContext: AuthContext { viewModel.authContext } +} + // MARK: - UITableViewDelegate extension SearchHashtagViewController: UITableViewDelegate { @@ -97,7 +102,7 @@ extension SearchHashtagViewController: UITableViewDelegate { case .hashtag(let data): switch data { case .mastodon(let tag): - let hashtagTimelineViewModel = HashtagTimelineViewModel(context: context, hashtag: tag.name) + let hashtagTimelineViewModel = HashtagTimelineViewModel(context: context, authContext: authContext, hashtag: tag.name) coordinator.present(scene: .hashtagTimeline(viewModel: hashtagTimelineViewModel), from: self, transition: .show) } diff --git a/TwidereX/Scene/Search/SearchResult/Hashtag/SearchHashtagViewModel+State.swift b/TwidereX/Scene/Search/SearchResult/Hashtag/SearchHashtagViewModel+State.swift index 7a84bf99..b7e839f5 100644 --- a/TwidereX/Scene/Search/SearchResult/Hashtag/SearchHashtagViewModel+State.swift +++ b/TwidereX/Scene/Search/SearchResult/Hashtag/SearchHashtagViewModel+State.swift @@ -71,18 +71,14 @@ extension SearchHashtagViewModel.State { } guard let viewModel = viewModel, let stateMachine = stateMachine else { return } - guard let authenticationContext = viewModel.context.authenticationService.activeAuthenticationContext - else { - stateMachine.enter(Fail.self) - return - } let searchText = viewModel.searchText - + let authenticationContext = viewModel.authContext.authenticationContext + if nextInput == nil { nextInput = { switch authenticationContext { - case .twitter(let authenticationContext): + case .twitter: assertionFailure() return nil case .mastodon(let authenticationContext): diff --git a/TwidereX/Scene/Search/SearchResult/Hashtag/SearchHashtagViewModel.swift b/TwidereX/Scene/Search/SearchResult/Hashtag/SearchHashtagViewModel.swift index fce02fc4..bbb78828 100644 --- a/TwidereX/Scene/Search/SearchResult/Hashtag/SearchHashtagViewModel.swift +++ b/TwidereX/Scene/Search/SearchResult/Hashtag/SearchHashtagViewModel.swift @@ -21,6 +21,7 @@ final class SearchHashtagViewModel { // input let context: AppContext + let authContext: AuthContext let listBatchFetchViewModel = ListBatchFetchViewModel() let viewDidAppear = PassthroughSubject() @Published var items: [HashtagItem] = [] @@ -41,8 +42,12 @@ final class SearchHashtagViewModel { return stateMachine }() - init(context: AppContext) { + init( + context: AppContext, + authContext: AuthContext + ) { self.context = context + self.authContext = authContext // end init $searchText diff --git a/TwidereX/Scene/Search/SearchResult/SearchResultViewController.swift b/TwidereX/Scene/Search/SearchResult/SearchResultViewController.swift index 641a910f..1391bd7e 100644 --- a/TwidereX/Scene/Search/SearchResult/SearchResultViewController.swift +++ b/TwidereX/Scene/Search/SearchResult/SearchResultViewController.swift @@ -126,13 +126,12 @@ extension SearchResultViewController: UISearchBarDelegate { func searchBarBookmarkButtonClicked(_ searchBar: UISearchBar) { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") guard let searchText = searchBar.text?.trimmingCharacters(in: .whitespacesAndNewlines), !searchText.isEmpty else { return } - guard let authenticationContext = context.authenticationService.activeAuthenticationContext else { return } Task { try await DataSourceFacade.responseToCreateSavedSearch( dependency: self, searchText: searchText, - authenticationContext: authenticationContext + authenticationContext: viewModel.authContext.authenticationContext ) } } diff --git a/TwidereX/Scene/Search/SearchResult/SearchResultViewModel.swift b/TwidereX/Scene/Search/SearchResult/SearchResultViewModel.swift index bd81c6b2..3ffee2dc 100644 --- a/TwidereX/Scene/Search/SearchResult/SearchResultViewModel.swift +++ b/TwidereX/Scene/Search/SearchResult/SearchResultViewModel.swift @@ -16,6 +16,7 @@ final class SearchResultViewModel { // input let context: AppContext + let authContext: AuthContext let _coordinator: SceneCoordinator // only use for `setup` var preferredScope: Scope? @Published var searchText: String = "" @@ -27,17 +28,16 @@ final class SearchResultViewModel { @Published var currentPageIndex = 0 @Published var userIdentifier: UserIdentifier? - init(context: AppContext, coordinator: SceneCoordinator) { + init( + context: AppContext, + authContext: AuthContext, + coordinator: SceneCoordinator + ) { self.context = context + self.authContext = authContext self._coordinator = coordinator - context.authenticationService.$activeAuthenticationContext - .receive(on: DispatchQueue.main) - .sink { [weak self] authenticationContext in - guard let self = self else { return } - self.setup(for: authenticationContext) - } - .store(in: &disposeBag) + self.setup(for: authContext.authenticationContext) } } @@ -72,7 +72,7 @@ extension SearchResultViewModel { case .twitter(let authenticationContext): scopes = [ .status(title: L10n.Scene.Search.Tabs.tweets), - .media(title: L10n.Scene.Search.Tabs.media), + // .media(title: L10n.Scene.Search.Tabs.media), .user(title: L10n.Scene.Search.Tabs.users), ] userIdentifier = UserIdentifier.twitter(.init(id: authenticationContext.userID)) @@ -102,28 +102,28 @@ extension SearchResultViewModel { switch scope { case .status: let _viewController = SearchTimelineViewController() - let _viewModel = SearchTimelineViewModel(context: context) + let _viewModel = SearchTimelineViewModel(context: context, authContext: authContext) _viewController.viewModel = _viewModel $searchText.assign(to: &_viewModel.$searchText) viewController = _viewController case .media: let _viewController = SearchMediaTimelineViewController() - let _viewModel = SearchMediaTimelineViewModel(context: context) + let _viewModel = SearchMediaTimelineViewModel(context: context, authContext: authContext) _viewController.viewModel = _viewModel $searchText.assign(to: &_viewModel.$searchText) viewController = _viewController case .user: let _viewController = SearchUserViewController() - _viewController.viewModel = SearchUserViewModel(context: context, kind: .friendship) + _viewController.viewModel = SearchUserViewModel(context: context, authContext: authContext, kind: .search) $searchText.assign(to: &_viewController.viewModel.$searchText) $userIdentifier.assign(to: &_viewController.viewModel.$userIdentifier) viewController = _viewController case .hashtag: let _viewController = SearchHashtagViewController() - _viewController.viewModel = SearchHashtagViewModel(context: context) + _viewController.viewModel = SearchHashtagViewModel(context: context, authContext: authContext) $searchText.assign(to: &_viewController.viewModel.$searchText) viewController = _viewController } // end switch diff --git a/TwidereX/Scene/Search/SearchResult/User/SearchUserViewController.swift b/TwidereX/Scene/Search/SearchResult/User/SearchUserViewController.swift index c8abfd94..995d0530 100644 --- a/TwidereX/Scene/Search/SearchResult/User/SearchUserViewController.swift +++ b/TwidereX/Scene/Search/SearchResult/User/SearchUserViewController.swift @@ -26,8 +26,8 @@ final class SearchUserViewController: UIViewController, NeedsDependency { lazy var tableView: UITableView = { let tableView = UITableView() - tableView.register(UserRelationshipStyleTableViewCell.self, forCellReuseIdentifier: String(describing: UserRelationshipStyleTableViewCell.self)) - tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) +// tableView.register(UserRelationshipStyleTableViewCell.self, forCellReuseIdentifier: String(describing: UserRelationshipStyleTableViewCell.self)) +// tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) tableView.rowHeight = UITableView.automaticDimension tableView.separatorStyle = .none return tableView @@ -101,6 +101,11 @@ extension SearchUserViewController: DeselectRowTransitionCoordinator { } } +// MARK: - AuthContextProvider +extension SearchUserViewController: AuthContextProvider { + var authContext: AuthContext { viewModel.authContext } +} + // MARK: - UITableViewDelegate extension SearchUserViewController: UITableViewDelegate, AutoGenerateTableViewDelegate { // sourcery:inline:SearchUserViewController.AutoGenerateTableViewDelegate diff --git a/TwidereX/Scene/Search/SearchResult/User/SearchUserViewModel+Diffable.swift b/TwidereX/Scene/Search/SearchResult/User/SearchUserViewModel+Diffable.swift index 28ec4d84..a1affa0e 100644 --- a/TwidereX/Scene/Search/SearchResult/User/SearchUserViewModel+Diffable.swift +++ b/TwidereX/Scene/Search/SearchResult/User/SearchUserViewModel+Diffable.swift @@ -12,7 +12,6 @@ import CoreData import CoreDataStack import AlamofireImage import Kingfisher -import TwidereUI extension SearchUserViewModel { @MainActor func setupDiffableDataSource( @@ -22,12 +21,9 @@ extension SearchUserViewModel { diffableDataSource = UserSection.diffableDataSource( tableView: tableView, context: context, + authContext: authContext, configuration: UserSection.Configuration( - userViewTableViewCellDelegate: userViewTableViewCellDelegate, - userViewConfigurationContext: .init( - listMembershipViewModel: listMembershipViewModel, - authenticationContext: context.authenticationService.activeAuthenticationContext - ) + userViewTableViewCellDelegate: userViewTableViewCellDelegate ) ) @@ -56,10 +52,10 @@ extension SearchUserViewModel { snapshot.appendSections([.main]) let newItems: [UserItem] = records.map { record in switch self.kind { - case .friendship: - return .user(record: record, style: .relationship) + case .search: + return .user(record: record, kind: .search) case .listMember: - return .user(record: record, style: .addListMember) + return .user(record: record, kind: .addListMember(self.listMembershipViewModel)) } // end switch } snapshot.appendItems(newItems, toSection: .main) diff --git a/TwidereX/Scene/Search/SearchResult/User/SearchUserViewModel+State.swift b/TwidereX/Scene/Search/SearchResult/User/SearchUserViewModel+State.swift index faf504fa..c1bf055d 100644 --- a/TwidereX/Scene/Search/SearchResult/User/SearchUserViewModel+State.swift +++ b/TwidereX/Scene/Search/SearchResult/User/SearchUserViewModel+State.swift @@ -72,13 +72,10 @@ extension SearchUserViewModel.State { } guard let viewModel = viewModel, let stateMachine = stateMachine else { return } - guard let authenticationContext = viewModel.context.authenticationService.activeAuthenticationContext - else { - stateMachine.enter(Fail.self) - return - } + let authenticationContext = viewModel.authContext.authenticationContext let searchText = viewModel.searchText + if nextInput == nil { nextInput = { switch authenticationContext { @@ -95,7 +92,7 @@ extension SearchUserViewModel.State { searchText: searchText, following: { switch viewModel.kind { - case .friendship: return false + case .search: return false case .listMember: return true } }(), diff --git a/TwidereX/Scene/Search/SearchResult/User/SearchUserViewModel.swift b/TwidereX/Scene/Search/SearchResult/User/SearchUserViewModel.swift index 677e2601..fc6b5153 100644 --- a/TwidereX/Scene/Search/SearchResult/User/SearchUserViewModel.swift +++ b/TwidereX/Scene/Search/SearchResult/User/SearchUserViewModel.swift @@ -14,7 +14,6 @@ import CoreDataStack import GameplayKit import TwitterSDK import TwidereCore -import TwidereUI final class SearchUserViewModel { @@ -24,6 +23,7 @@ final class SearchUserViewModel { // input let context: AppContext + let authContext: AuthContext let kind: Kind let userRecordFetchedResultController: UserRecordFetchedResultController let listMembershipViewModel: ListMembershipViewModel? @@ -49,9 +49,11 @@ final class SearchUserViewModel { init( context: AppContext, + authContext: AuthContext, kind: SearchUserViewModel.Kind ) { self.context = context + self.authContext = authContext self.kind = kind self.userRecordFetchedResultController = UserRecordFetchedResultController( managedObjectContext: context.managedObjectContext @@ -87,7 +89,7 @@ final class SearchUserViewModel { extension SearchUserViewModel { enum Kind { - case friendship + case search case listMember(list: ListRecord) } } diff --git a/TwidereX/Scene/Search/Trend/TrendViewController.swift b/TwidereX/Scene/Search/Trend/TrendViewController.swift index 80b3d0e5..605dd07d 100644 --- a/TwidereX/Scene/Search/Trend/TrendViewController.swift +++ b/TwidereX/Scene/Search/Trend/TrendViewController.swift @@ -68,6 +68,34 @@ extension TrendViewController { tableView.deselectRow(with: transitionCoordinator, animated: animated) } + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + viewModel.viewLayoutFrame.update(view: view) + } + + override func viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + + viewModel.viewLayoutFrame.update(view: view) + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + coordinator.animate {[weak self] _ in + guard let self = self else { return } + self.viewModel.viewLayoutFrame.update(view: self.view) + } completion: { _ in + // do nothing + } + } + +} + +// MARK: - AuthContextProvider +extension TrendViewController: AuthContextProvider { + var authContext: AuthContext { viewModel.authContext } } // MARK: - UITableViewDelegate @@ -88,6 +116,7 @@ extension TrendViewController: UITableViewDelegate { case .mastodon(let tag): let hashtagTimelineViewModel = HashtagTimelineViewModel( context: context, + authContext: authContext, hashtag: tag.name ) coordinator.present( diff --git a/TwidereX/Scene/Search/Trend/TrendViewModel+Diffable.swift b/TwidereX/Scene/Search/Trend/TrendViewModel+Diffable.swift index 1cb529d7..675b69ea 100644 --- a/TwidereX/Scene/Search/Trend/TrendViewModel+Diffable.swift +++ b/TwidereX/Scene/Search/Trend/TrendViewModel+Diffable.swift @@ -16,7 +16,9 @@ extension TrendViewModel { diffableDataSource = SearchSection.diffableDataSource( tableView: tableView, context: context, - configuration: SearchSection.Configuration() + configuration: SearchSection.Configuration( + viewLayoutFramePublisher: $viewLayoutFrame + ) ) var snapshot = NSDiffableDataSourceSnapshot() @@ -31,7 +33,9 @@ extension TrendViewModel { .map { trendGroupRecords, trendGroupIndex in let trendItems: [SearchItem] = trendGroupRecords[trendGroupIndex] .flatMap { group in - return group.trends.map { .trend(trend: $0) } + return group.trends + .removingDuplicates() + .map { .trend(trend: $0) } } ?? [] return trendItems } diff --git a/TwidereX/Scene/Search/Trend/TrendViewModel.swift b/TwidereX/Scene/Search/Trend/TrendViewModel.swift index cad1ce38..4e5d6c0f 100644 --- a/TwidereX/Scene/Search/Trend/TrendViewModel.swift +++ b/TwidereX/Scene/Search/Trend/TrendViewModel.swift @@ -20,9 +20,12 @@ final class TrendViewModel: ObservableObject { // input let context: AppContext + let authContext: AuthContext let trendService: TrendService @Published var trendGroupIndex: TrendService.TrendGroupIndex = .none @Published var searchText = "" + + @Published public var viewLayoutFrame = ViewLayoutFrame() // output var diffableDataSource: UITableViewDiffableDataSource? @@ -32,28 +35,24 @@ final class TrendViewModel: ObservableObject { let activeTwitterTrendPlacePublisher = PassthroughSubject() - init(context: AppContext) { + init( + context: AppContext, + authContext: AuthContext + ) { self.context = context + self.authContext = authContext self.trendService = TrendService(apiService: context.apiService) // end init - context.authenticationService.$activeAuthenticationContext - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [weak self] authenticationContext in - guard let self = self else { return } - - switch authenticationContext { - case .twitter: - let placeID = TrendViewModel.defaultTwitterTrendPlace?.woeid ?? 1 // fallback to world-wide "1" - self.trendGroupIndex = .twitter(placeID: placeID) - case .mastodon(let authenticationContext): - self.trendGroupIndex = .mastodon(domain: authenticationContext.domain) - case nil: - self.trendGroupIndex = .none - } - } - .store(in: &disposeBag) + switch authContext.authenticationContext { + case .twitter: + let placeID = TrendViewModel.defaultTwitterTrendPlace?.woeid ?? 1 // fallback to world-wide "1" + self.trendGroupIndex = .twitter(placeID: placeID) + case .mastodon(let authenticationContext): + self.trendGroupIndex = .mastodon(domain: authenticationContext.domain) + case nil: + self.trendGroupIndex = .none + } Publishers.CombineLatest( $trendGroupIndex, @@ -81,7 +80,7 @@ final class TrendViewModel: ObservableObject { extension TrendViewModel { func fetchTrendPlaces() async throws { guard twitterTrendPlaces.isEmpty else { return } - guard case let .twitter(authenticationContext) = context.authenticationService.activeAuthenticationContext else { return } + guard case let .twitter(authenticationContext) = authContext.authenticationContext else { return } let response = try await context.apiService.twitterTrendPlaces(authenticationContext: authenticationContext) twitterTrendPlaces = response.value .filter { $0.parentID == 1 } diff --git a/TwidereX/Scene/Search/TrendPlace/TrendPlaceViewController.swift b/TwidereX/Scene/Search/TrendPlace/TrendPlaceViewController.swift index e0d227cb..ce09efce 100644 --- a/TwidereX/Scene/Search/TrendPlace/TrendPlaceViewController.swift +++ b/TwidereX/Scene/Search/TrendPlace/TrendPlaceViewController.swift @@ -10,6 +10,7 @@ import os.log import UIKit import SwiftUI import Combine +import TwidereCore final class TrendPlaceViewController: UIViewController, NeedsDependency { diff --git a/TwidereX/Scene/Setting/About/AboutView.swift b/TwidereX/Scene/Setting/About/AboutView.swift index 1f697b70..50c93577 100644 --- a/TwidereX/Scene/Setting/About/AboutView.swift +++ b/TwidereX/Scene/Setting/About/AboutView.swift @@ -8,7 +8,6 @@ import SwiftUI import TwidereAsset -import TwidereUI struct AboutView: View { @@ -116,18 +115,20 @@ struct AboutView: View { struct AboutView_Previews: PreviewProvider { static var previews: some View { Group { - AboutView(viewModel: AboutViewModel()) - AboutView(viewModel: AboutViewModel()) - .preferredColorScheme(.dark) - AboutView(viewModel: AboutViewModel()) - .previewDevice("iPhone SE") - AboutView(viewModel: AboutViewModel()) - .previewDevice("iPhone 13 mini") - AboutView(viewModel: AboutViewModel()) - .previewDevice("iPhone 8") - AboutView(viewModel: AboutViewModel()) - .previewDevice("iPad mini (6th generation)") - } + if let authContext = AuthContext.mock(context: AppContext.shared) { + AboutView(viewModel: AboutViewModel(authContext: authContext)) + AboutView(viewModel: AboutViewModel(authContext: authContext)) + .preferredColorScheme(.dark) + AboutView(viewModel: AboutViewModel(authContext: authContext)) + .previewDevice("iPhone SE") + AboutView(viewModel: AboutViewModel(authContext: authContext)) + .previewDevice("iPhone 13 mini") + AboutView(viewModel: AboutViewModel(authContext: authContext)) + .previewDevice("iPhone 8") + AboutView(viewModel: AboutViewModel(authContext: authContext)) + .previewDevice("iPad mini (6th generation)") + } + } // end Group } } diff --git a/TwidereX/Scene/Setting/About/AboutViewController.swift b/TwidereX/Scene/Setting/About/AboutViewController.swift index 38ef669b..2ba9fdc3 100644 --- a/TwidereX/Scene/Setting/About/AboutViewController.swift +++ b/TwidereX/Scene/Setting/About/AboutViewController.swift @@ -17,7 +17,7 @@ final class AboutViewController: UIViewController, NeedsDependency { weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } var disposeBag = Set() - let viewModel = AboutViewModel() + var viewModel: AboutViewModel! } @@ -51,16 +51,15 @@ extension AboutViewController { let url = URL(string: "https://github.com/TwidereProject/TwidereX-iOS")! self.coordinator.present(scene: .safari(url: url.absoluteString), from: nil, transition: .safariPresent(animated: true, completion: nil)) case .twitter: - switch self.context.authenticationService.activeAuthenticationContext { + switch self.viewModel.authContext.authenticationContext { case .twitter: let profileViewModel = RemoteProfileViewModel( context: self.context, + authContext: self.viewModel.authContext, profileContext: .twitter(.username("TwidereProject")) ) self.coordinator.present(scene: .profile(viewModel: profileViewModel), from: self, transition: .show) case .mastodon: - fallthrough - case .none: let url = URL(string: "https://twitter.com/twidereproject")! self.coordinator.present(scene: .safari(url: url.absoluteString), from: nil, transition: .safariPresent(animated: true, completion: nil)) } diff --git a/TwidereX/Scene/Setting/About/AboutViewModel.swift b/TwidereX/Scene/Setting/About/AboutViewModel.swift index 2e15c5ff..cebbf5c0 100644 --- a/TwidereX/Scene/Setting/About/AboutViewModel.swift +++ b/TwidereX/Scene/Setting/About/AboutViewModel.swift @@ -18,12 +18,13 @@ final class AboutViewModel: ObservableObject { var disposeBag = Set() // input + let authContext: AuthContext // output let entryPublisher = PassthroughSubject() - init() { - + init(authContext: AuthContext) { + self.authContext = authContext } } diff --git a/TwidereX/Scene/Setting/AccountPreference/AccountPreferenceView.swift b/TwidereX/Scene/Setting/AccountPreference/AccountPreferenceView.swift index f185906c..5828d0da 100644 --- a/TwidereX/Scene/Setting/AccountPreference/AccountPreferenceView.swift +++ b/TwidereX/Scene/Setting/AccountPreference/AccountPreferenceView.swift @@ -8,7 +8,6 @@ import Foundation import SwiftUI -import TwidereUI enum AccountPreferenceListEntry: Hashable { case muted @@ -20,7 +19,7 @@ enum AccountPreferenceListEntry: Hashable { switch self { case .muted: return L10n.Scene.Settings.Account.mutedPeople case .blocked: return L10n.Scene.Settings.Account.blockedPeople - case .accountSettings: return L10n.Scene.Settings.Account.accountSettings + case .accountSettings: return "Account Settings" // TODO: i18n case .signout: return L10n.Common.Controls.Actions.signOut } } @@ -53,10 +52,9 @@ struct AccountPreferenceView: View { List { // user header section Section { - UserContentView(viewModel: .init( - user: viewModel.user, - accessoryType: .none - )) + if let userViewModel = viewModel.userViewModel { + UserView(viewModel: userViewModel) + } } // notification section if let viewModel = viewModel.mastodonNotificationSectionViewModel { diff --git a/TwidereX/Scene/Setting/AccountPreference/AccountPreferenceViewController.swift b/TwidereX/Scene/Setting/AccountPreference/AccountPreferenceViewController.swift index 2e810c5a..c1559e27 100644 --- a/TwidereX/Scene/Setting/AccountPreference/AccountPreferenceViewController.swift +++ b/TwidereX/Scene/Setting/AccountPreference/AccountPreferenceViewController.swift @@ -69,3 +69,10 @@ extension AccountPreferenceViewController { } } + +// MARK: - AuthContextProvider +extension AccountPreferenceViewController: AuthContextProvider { + var authContext: AuthContext { + return viewModel.authContext + } +} diff --git a/TwidereX/Scene/Setting/AccountPreference/AccountPreferenceViewModel.swift b/TwidereX/Scene/Setting/AccountPreference/AccountPreferenceViewModel.swift index c7c64fa4..950beb44 100644 --- a/TwidereX/Scene/Setting/AccountPreference/AccountPreferenceViewModel.swift +++ b/TwidereX/Scene/Setting/AccountPreference/AccountPreferenceViewModel.swift @@ -17,7 +17,7 @@ final class AccountPreferenceViewModel: ObservableObject { // input let context: AppContext - let auth: AuthContext + let authContext: AuthContext let user: UserObject // notification @@ -29,19 +29,26 @@ final class AccountPreferenceViewModel: ObservableObject { @Published var isMentionEnabled = true // output + @Published var userViewModel: UserView.ViewModel? let listEntryPublisher = PassthroughSubject() init( context: AppContext, - auth: AuthContext, + authContext: AuthContext, user: UserObject ) { self.context = context - self.auth = auth + self.authContext = authContext self.user = user // end init setupNotificationSource() + userViewModel = UserView.ViewModel( + user: user, + authContext: authContext, + kind: .plain, + delegate: nil + ) } deinit { @@ -60,7 +67,7 @@ extension AccountPreferenceViewModel { mastodonNotificationSectionViewModel = user.mastodonAuthentication?.notificationSubscription.flatMap { return .init( context: context, - auth: auth, + authContext: authContext, notificationSubscription: $0 ) } diff --git a/TwidereX/Scene/Setting/AccountPreference/MastodonNotification/MastodonNotificationSectionView.swift b/TwidereX/Scene/Setting/AccountPreference/MastodonNotification/MastodonNotificationSectionView.swift index ec17566a..ac6a588a 100644 --- a/TwidereX/Scene/Setting/AccountPreference/MastodonNotification/MastodonNotificationSectionView.swift +++ b/TwidereX/Scene/Setting/AccountPreference/MastodonNotification/MastodonNotificationSectionView.swift @@ -117,7 +117,7 @@ extension MastodonNotificationSectionView { viewModel.mentionPreference = newValue viewModel.updateNotificationSubscription { notificationSubscription in let mentionPreference = MastodonNotificationSubscription.MentionPreference(preference: newValue) - notificationSubscription.update(mentionPreference: mentionPreference) + notificationSubscription.update(mentionPreferenceTransient: mentionPreference) } } )) { @@ -136,7 +136,7 @@ extension MastodonNotificationSectionView { extension MastodonNotificationSubscription.MentionPreference.Preference { fileprivate var title: String { switch self { - case .everyone: return "Everyone" + case .everyone: return "Everyone" // TODO: i18n case .follows: return "Follows" } } diff --git a/TwidereX/Scene/Setting/AccountPreference/MastodonNotification/MastodonNotificationSectionViewModel.swift b/TwidereX/Scene/Setting/AccountPreference/MastodonNotification/MastodonNotificationSectionViewModel.swift index 0f1c3f7d..4ff5da7d 100644 --- a/TwidereX/Scene/Setting/AccountPreference/MastodonNotification/MastodonNotificationSectionViewModel.swift +++ b/TwidereX/Scene/Setting/AccountPreference/MastodonNotification/MastodonNotificationSectionViewModel.swift @@ -15,7 +15,7 @@ final class MastodonNotificationSectionViewModel: ObservableObject { // input let context: AppContext - let auth: AuthContext + let authContext: AuthContext let notificationSubscription: MastodonNotificationSubscription // output @@ -31,11 +31,11 @@ final class MastodonNotificationSectionViewModel: ObservableObject { init( context: AppContext, - auth: AuthContext, + authContext: AuthContext, notificationSubscription: MastodonNotificationSubscription ) { self.context = context - self.auth = auth + self.authContext = authContext self.notificationSubscription = notificationSubscription self.isActive = notificationSubscription.isActive self.isNewFollowEnabled = notificationSubscription.follow @@ -43,7 +43,7 @@ final class MastodonNotificationSectionViewModel: ObservableObject { self.isFavoriteEnabled = notificationSubscription.favourite self.isPollEnabled = notificationSubscription.poll self.isMentionEnabled = notificationSubscription.mention - self.mentionPreference = notificationSubscription.mentionPreference.preference + self.mentionPreference = notificationSubscription.mentionPreferenceTransient.preference // end init notificationSubscription.publisher(for: \.isActive) @@ -66,7 +66,7 @@ final class MastodonNotificationSectionViewModel: ObservableObject { .receive(on: DispatchQueue.main) .assign(to: &$isMentionEnabled) - notificationSubscription.publisher(for: \.mentionPreference) + notificationSubscription.publisher(for: \.mentionPreferenceTransient) .receive(on: DispatchQueue.main) .map { $0.preference } .assign(to: &$mentionPreference) @@ -89,7 +89,7 @@ extension MastodonNotificationSectionViewModel { action(object) } - await context.notificationService.notifySubscriber(authenticationContext: auth.authenticationContext) + await context.notificationService.notifySubscriber(authenticationContext: authContext.authenticationContext) } // end Task } diff --git a/TwidereX/Scene/Setting/AppearancePreference/AppIconPreferenceView.swift b/TwidereX/Scene/Setting/AppIconPreference/AppIconPreferenceView.swift similarity index 99% rename from TwidereX/Scene/Setting/AppearancePreference/AppIconPreferenceView.swift rename to TwidereX/Scene/Setting/AppIconPreference/AppIconPreferenceView.swift index 11c91f13..7878ca70 100644 --- a/TwidereX/Scene/Setting/AppearancePreference/AppIconPreferenceView.swift +++ b/TwidereX/Scene/Setting/AppIconPreference/AppIconPreferenceView.swift @@ -9,7 +9,7 @@ import os.log import Foundation import SwiftUI -import TwidereCommon +import TwidereCore struct AppIconPreferenceView: View { diff --git a/TwidereX/Scene/Setting/AppearancePreference/AppearancePreferenceView.swift b/TwidereX/Scene/Setting/AppearancePreference/AppearancePreferenceView.swift deleted file mode 100644 index 195cedcc..00000000 --- a/TwidereX/Scene/Setting/AppearancePreference/AppearancePreferenceView.swift +++ /dev/null @@ -1,83 +0,0 @@ -// -// AppearanceView.swift -// TwidereX -// -// Created by MainasuK on 2022-4-1. -// Copyright © 2022 Twidere. All rights reserved. -// - -import SwiftUI -import TwidereLocalization -import TwidereUI - -struct AppearancePreferenceView: View { - - @ObservedObject var viewModel: AppearancePreferenceViewModel - - @State private var isTranslateButtonPreferenceSheetPresented = false - - - var appIconRow: some View { - Button { - - } label: { - HStack { - Text(L10n.Scene.Settings.Appearance.appIcon) - Spacer() - Image(uiImage: UIImage(named: "\(viewModel.alternateIconNamePreference.iconName)") ?? UIImage()) - .cornerRadius(4) - } - } - .tint(Color(uiColor: .label)) - } - - var body: some View { - List { - Section { - NavigationLink { - AppIconPreferenceView() - } label: { - appIconRow - } - } header: { - EmptyView() - } - Section { - // Translate Button - NavigationLink { - TranslateButtonPreferenceView(preference: viewModel.translateButtonPreference) - } label: { - Text(L10n.Scene.Settings.Appearance.Translation.translateButton) - .tint(Color(uiColor: .label)) - .badge(viewModel.translateButtonPreference.text) - } - // Service - NavigationLink { - TranslationServicePreferenceView(preference: viewModel.translationServicePreference) - } label: { - Text(L10n.Scene.Settings.Appearance.Translation.service) - .tint(Color(uiColor: .label)) - .badge(viewModel.translationServicePreference.text) - } - } header: { - Text(L10n.Scene.Settings.Appearance.SectionHeader.translation) - } - .textCase(nil) - - - } - .listStyle(InsetGroupedListStyle()) - } -} - -#if DEBUG -struct AppearanceView_Previews: PreviewProvider { - static var previews: some View { - NavigationView { - AppearancePreferenceView(viewModel: AppearancePreferenceViewModel(context: .shared)) - .navigationBarTitle(Text(L10n.Scene.Settings.Appearance.title)) - .navigationBarTitleDisplayMode(.inline) - } - } -} -#endif diff --git a/TwidereX/Scene/Setting/AppearancePreference/AppearancePreferenceViewModel.swift b/TwidereX/Scene/Setting/AppearancePreference/AppearancePreferenceViewModel.swift deleted file mode 100644 index 01f828a2..00000000 --- a/TwidereX/Scene/Setting/AppearancePreference/AppearancePreferenceViewModel.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// AppearancePreferenceViewModel.swift -// TwidereX -// -// Created by MainasuK on 2022-4-1. -// Copyright © 2022 Twidere. All rights reserved. -// - -import os.log -import UIKit -import SwiftUI -import Combine -import CoreDataStack -import TwidereCommon -import TwidereCore -import TwitterSDK -import MastodonSDK - -final class AppearancePreferenceViewModel: ObservableObject { - - // input - let context: AppContext - - // output - // App Icon - @Published var alternateIconNamePreference = UserDefaults.shared.alternateIconNamePreference - - // Translation - @Published var translateButtonPreference = UserDefaults.shared.translateButtonPreference - @Published var translationServicePreference = UserDefaults.shared.translationServicePreference - - init( - context: AppContext - ) { - self.context = context - // end init - - // App Icon - UserDefaults.shared.publisher(for: \.alternateIconNamePreference) - .assign(to: &$alternateIconNamePreference) - - // Translation - UserDefaults.shared.publisher(for: \.translateButtonPreference) - .assign(to: &$translateButtonPreference) - UserDefaults.shared.publisher(for: \.translationServicePreference) - .assign(to: &$translationServicePreference) - } - -} diff --git a/TwidereX/Scene/Setting/BehaviorsPreference/BehaviorsPreferenceView.swift b/TwidereX/Scene/Setting/BehaviorsPreference/BehaviorsPreferenceView.swift new file mode 100644 index 00000000..004f81d5 --- /dev/null +++ b/TwidereX/Scene/Setting/BehaviorsPreference/BehaviorsPreferenceView.swift @@ -0,0 +1,67 @@ +// +// BehaviorsPreferenceView.swift +// TwidereX +// +// Created by MainasuK on 2022-7-27. +// Copyright © 2022 Twidere. All rights reserved. +// + +import SwiftUI +import TwidereLocalization + +struct BehaviorsPreferenceView: View { + + @ObservedObject var viewModel: BehaviorsPreferenceViewModel + + var body: some View { + List { + // Tab Bar + Section { + Toggle(isOn: $viewModel.preferredTabBarLabelDisplay) { + Text(verbatim: L10n.Scene.Settings.Behaviors.TabBarSection.showTabBarLabels) + } + Picker(selection: $viewModel.tabBarTapScrollPreference) { + ForEach(UserDefaults.TabBarTapScrollPreference.allCases, id: \.self) { preference in + Text(preference.title) + } + } label: { + Text(verbatim: L10n.Scene.Settings.Behaviors.TabBarSection.tapTabBarScrollToTop) + } + } header: { + Text(verbatim: L10n.Scene.Settings.Behaviors.TabBarSection.tabBar) + .textCase(nil) + } + // Timeline Refreshing + Section { + Toggle(isOn: $viewModel.preferredTimelineAutoRefresh) { + Text(verbatim: L10n.Scene.Settings.Behaviors.TimelineRefreshingSection.automaticallyRefreshTimeline) + } + if viewModel.preferredTimelineAutoRefresh { + Picker(selection: $viewModel.timelineRefreshInterval) { + ForEach(UserDefaults.TimelineRefreshInterval.allCases, id: \.self) { preference in + Text(preference.title) + } + } label: { + Text(verbatim: L10n.Scene.Settings.Behaviors.TimelineRefreshingSection.refreshInterval) + } + } + Toggle(isOn: $viewModel.preferredTimelineResetToTop) { + Text(verbatim: L10n.Scene.Settings.Behaviors.TimelineRefreshingSection.resetToTop) + } + } header: { + Text(verbatim: L10n.Scene.Settings.Behaviors.TimelineRefreshingSection.timelineRefreshing) + .textCase(nil) + } + // History + Section { + Toggle(isOn: $viewModel.preferredEnableHistory) { + Text(verbatim: L10n.Scene.Settings.Behaviors.HistorySection.enableHistoryRecord) + } + } header: { + Text(verbatim: L10n.Scene.Settings.Behaviors.HistorySection.history) + .textCase(nil) + } + } + } + +} diff --git a/TwidereX/Scene/Setting/AppearancePreference/AppearancePreferenceViewController.swift b/TwidereX/Scene/Setting/BehaviorsPreference/BehaviorsPreferenceViewController.swift similarity index 69% rename from TwidereX/Scene/Setting/AppearancePreference/AppearancePreferenceViewController.swift rename to TwidereX/Scene/Setting/BehaviorsPreference/BehaviorsPreferenceViewController.swift index 3d756d40..2f07f53f 100644 --- a/TwidereX/Scene/Setting/AppearancePreference/AppearancePreferenceViewController.swift +++ b/TwidereX/Scene/Setting/BehaviorsPreference/BehaviorsPreferenceViewController.swift @@ -1,8 +1,8 @@ // -// AppearancePreferenceViewController.swift +// BehaviorsPreferenceViewController.swift // TwidereX // -// Created by MainasuK on 2022-4-1. +// Created by MainasuK on 2022-7-27. // Copyright © 2022 Twidere. All rights reserved. // @@ -12,31 +12,31 @@ import Combine import SwiftUI import TwidereLocalization -final class AppearancePreferenceViewController: UIViewController, NeedsDependency { +final class BehaviorsPreferenceViewController: UIViewController, NeedsDependency { - let logger = Logger(subsystem: "AppearancePreferenceViewController", category: "ViewController") + let logger = Logger(subsystem: "BehaviorsPreferenceViewController", category: "ViewController") weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } var disposeBag = Set() - private(set) lazy var viewModel = AppearancePreferenceViewModel(context: context) - private(set) lazy var appearanceView = AppearancePreferenceView(viewModel: viewModel) + var viewModel: BehaviorsPreferenceViewModel! + private(set) lazy var behaviorsPreferenceView = BehaviorsPreferenceView(viewModel: viewModel) deinit { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) } - + } -extension AppearancePreferenceViewController { +extension BehaviorsPreferenceViewController { override func viewDidLoad() { super.viewDidLoad() - title = L10n.Scene.Settings.Appearance.title + title = L10n.Scene.Settings.Behaviors.title - let hostingViewController = UIHostingController(rootView: appearanceView) + let hostingViewController = UIHostingController(rootView: behaviorsPreferenceView) addChild(hostingViewController) hostingViewController.view.translatesAutoresizingMaskIntoConstraints = false view.addSubview(hostingViewController.view) diff --git a/TwidereX/Scene/Setting/BehaviorsPreference/BehaviorsPreferenceViewModel.swift b/TwidereX/Scene/Setting/BehaviorsPreference/BehaviorsPreferenceViewModel.swift new file mode 100644 index 00000000..d748ddda --- /dev/null +++ b/TwidereX/Scene/Setting/BehaviorsPreference/BehaviorsPreferenceViewModel.swift @@ -0,0 +1,125 @@ +// +// BehaviorsPreferenceViewModel.swift +// TwidereX +// +// Created by MainasuK on 2022-7-27. +// Copyright © 2022 Twidere. All rights reserved. +// + +import os.log +import UIKit +import SwiftUI +import Combine +import CoreDataStack +import TwidereCore +import TwitterSDK +import MastodonSDK + +final class BehaviorsPreferenceViewModel: ObservableObject { + + var disposeBag = Set() + + // input + let context: AppContext + + // Tab Bar + @Published var preferredTabBarLabelDisplay = UserDefaults.shared.preferredTabBarLabelDisplay + @Published var tabBarTapScrollPreference = UserDefaults.shared.tabBarTapScrollPreference + + // Timeline Refreshing + @Published var preferredTimelineAutoRefresh = UserDefaults.shared.preferredTimelineAutoRefresh + @Published var timelineRefreshInterval = UserDefaults.shared.timelineRefreshInterval + @Published var preferredTimelineResetToTop = UserDefaults.shared.preferredTimelineResetToTop + + // History + @Published var preferredEnableHistory = UserDefaults.shared.preferredEnableHistory + + // output + + init( + context: AppContext + ) { + self.context = context + // end init + + // preferredTabBarLabelDisplay + UserDefaults.shared.publisher(for: \.preferredTabBarLabelDisplay) + .removeDuplicates() + .assign(to: &$preferredTabBarLabelDisplay) + $preferredTabBarLabelDisplay + .sink { preferredTabBarLabelDisplay in + UserDefaults.shared.preferredTabBarLabelDisplay = preferredTabBarLabelDisplay + } + .store(in: &disposeBag) + + // tabBarTapScrollPreference + UserDefaults.shared.publisher(for: \.tabBarTapScrollPreference) + .removeDuplicates() + .assign(to: &$tabBarTapScrollPreference) + $tabBarTapScrollPreference + .sink { tabBarTapScrollPreference in + UserDefaults.shared.tabBarTapScrollPreference = tabBarTapScrollPreference + } + .store(in: &disposeBag) + + // preferredTimelineAutoRefresh + UserDefaults.shared.publisher(for: \.preferredTimelineAutoRefresh) + .removeDuplicates() + .assign(to: &$preferredTimelineAutoRefresh) + $preferredTimelineAutoRefresh + .sink { preferredTimelineAutoRefresh in + UserDefaults.shared.preferredTimelineAutoRefresh = preferredTimelineAutoRefresh + } + .store(in: &disposeBag) + + // timelineRefreshInterval + UserDefaults.shared.publisher(for: \.timelineRefreshInterval) + .removeDuplicates() + .assign(to: &$timelineRefreshInterval) + $timelineRefreshInterval + .sink { timelineRefreshInterval in + UserDefaults.shared.timelineRefreshInterval = timelineRefreshInterval + } + .store(in: &disposeBag) + + // preferredTimelineResetToTop + UserDefaults.shared.publisher(for: \.preferredTimelineResetToTop) + .removeDuplicates() + .assign(to: &$preferredTimelineResetToTop) + $preferredTimelineResetToTop + .sink { preferredTimelineResetToTop in + UserDefaults.shared.preferredTimelineResetToTop = preferredTimelineResetToTop + } + .store(in: &disposeBag) + + // preferredEnableHistory + UserDefaults.shared.publisher(for: \.preferredEnableHistory) + .removeDuplicates() + .assign(to: &$preferredEnableHistory) + $preferredEnableHistory + .sink { preferredEnableHistory in + UserDefaults.shared.preferredEnableHistory = preferredEnableHistory + } + .store(in: &disposeBag) + } + +} + +extension UserDefaults.TabBarTapScrollPreference { + var title: String { + switch self { + case .single: return L10n.Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTopOption.singleTap + case .double: return L10n.Scene.Settings.Behaviors.TimelineRefreshingSection.ResetToTopOption.doubleTap + } + } +} + +extension UserDefaults.TimelineRefreshInterval { + var title: String { + switch self { + case ._60s: return L10n.Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption._60Seconds + case ._120s: return L10n.Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption._120Seconds + case ._300s: return L10n.Scene.Settings.Behaviors.TimelineRefreshingSection.RefreshIntervalOption._300Seconds + } + } +} diff --git a/TwidereX/Scene/Setting/Developer/DeveloperView.swift b/TwidereX/Scene/Setting/Developer/DeveloperView.swift index 39807056..106dc8a3 100644 --- a/TwidereX/Scene/Setting/Developer/DeveloperView.swift +++ b/TwidereX/Scene/Setting/Developer/DeveloperView.swift @@ -71,9 +71,11 @@ struct DeveloperView: View { struct DeveloperView_Previews: PreviewProvider { static var previews: some View { Group { - DeveloperView(viewModel: DeveloperViewModel()) - DeveloperView(viewModel: DeveloperViewModel()) - .preferredColorScheme(.dark) + if let authContext = AuthContext.mock(context: .shared) { + DeveloperView(viewModel: DeveloperViewModel(authContext: authContext)) + DeveloperView(viewModel: DeveloperViewModel(authContext: authContext)) + .preferredColorScheme(.dark) + } } } } diff --git a/TwidereX/Scene/Setting/Developer/DeveloperViewController.swift b/TwidereX/Scene/Setting/Developer/DeveloperViewController.swift index aba23dd6..59e05e6c 100644 --- a/TwidereX/Scene/Setting/Developer/DeveloperViewController.swift +++ b/TwidereX/Scene/Setting/Developer/DeveloperViewController.swift @@ -20,7 +20,7 @@ final class DeveloperViewController: UIViewController, NeedsDependency { weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } - let viewModel = DeveloperViewModel() + var viewModel: DeveloperViewModel! private(set) lazy var developerView = DeveloperView(viewModel: viewModel) } @@ -43,12 +43,16 @@ extension DeveloperViewController { hostingViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) - if case let .twitter(authenticationContext) = context.authenticationService.activeAuthenticationContext { + if case let .twitter(authenticationContext) = viewModel.authContext.authenticationContext { Task { @MainActor in - viewModel.fetching = true - defer { viewModel.fetching = false } - let response = try await self.context.apiService.rateLimitStatus(authorization: authenticationContext.authorization) - viewModel.rateLimitStatusResources.value = response.value.resources + do { + viewModel.fetching = true + let response = try await self.context.apiService.rateLimitStatus(authorization: authenticationContext.authorization) + viewModel.rateLimitStatusResources.value = response.value.resources + } catch { + // do nothing + } + self.viewModel.fetching = false } // end Task } } diff --git a/TwidereX/Scene/Setting/Developer/DeveloperViewModel.swift b/TwidereX/Scene/Setting/Developer/DeveloperViewModel.swift index 4b6e0a31..cea3579a 100644 --- a/TwidereX/Scene/Setting/Developer/DeveloperViewModel.swift +++ b/TwidereX/Scene/Setting/Developer/DeveloperViewModel.swift @@ -18,6 +18,7 @@ final class DeveloperViewModel: ObservableObject { var disposeBag = Set() // input + let authContext: AuthContext let rateLimitStatusResources = CurrentValueSubject(nil) @Published var resourceFilterOption: DeveloperViewModel.ResourceFilterOption = .used @@ -25,7 +26,12 @@ final class DeveloperViewModel: ObservableObject { @Published var fetching = false @Published var sections: [Section] = [] - init() { + init( + authContext: AuthContext + ) { + self.authContext = authContext + // end init + Publishers.CombineLatest( $resourceFilterOption.eraseToAnyPublisher(), rateLimitStatusResources diff --git a/TwidereX/Scene/Setting/DisplayPreference/DisplayPreferenceView.swift b/TwidereX/Scene/Setting/DisplayPreference/DisplayPreferenceView.swift new file mode 100644 index 00000000..dc2ad93a --- /dev/null +++ b/TwidereX/Scene/Setting/DisplayPreference/DisplayPreferenceView.swift @@ -0,0 +1,85 @@ +// +// DisplayPreferenceView.swift +// TwidereX +// +// Created by MainasuK on 2022-7-25. +// Copyright © 2022 Twidere. All rights reserved. +// + +import Foundation +import SwiftUI +import Combine + +struct DisplayPreferenceView: View { + + @ObservedObject var viewModel: DisplayPreferenceViewModel + + @State var threadStatusViewHeight: CGFloat = .zero + + var body: some View { + List { + Section { + StatusView(viewModel: StatusView.ViewModel.prototype( + viewLayoutFramePublisher: viewModel.$viewLayoutFrame + )) +// PrototypeStatusViewRepresentable( +// style: .timeline, +// configurationContext: StatusView.ConfigurationContext( +// authContext: viewModel.authContext, +// dateTimeProvider: DateTimeSwiftProvider(), +// twitterTextProvider: OfficialTwitterTextProvider() +// ), +// height: $timelineStatusViewHeight +// ) + } header: { + Text(verbatim: L10n.Scene.Settings.Display.SectionHeader.preview) + .textCase(nil) + } + + // Avatar + Section { + avatarStylePicker + } header: { + Text(verbatim: L10n.Scene.Settings.Display.SectionHeader.avatar) + } // end Section + + // Translation + Section { + // Translate Button + Picker(selection: $viewModel.translateButtonPreference) { + ForEach(UserDefaults.TranslateButtonPreference.allCases, id: \.self) { preference in + Text(preference.text) + } + } label: { + Text(L10n.Scene.Settings.Appearance.Translation.translateButton) + } + // Translate Service + Picker(selection: $viewModel.translationServicePreference) { + ForEach(UserDefaults.TranslationServicePreference.allCases, id: \.self) { preference in + Text(preference.text) + } + } label: { + Text(L10n.Scene.Settings.Appearance.Translation.service) + } + } header: { + Text(verbatim: L10n.Scene.Settings.Appearance.SectionHeader.translation) + .textCase(nil) + } + } + } + +} + +extension DisplayPreferenceView { + + var avatarStylePicker: some View { + Picker(selection: $viewModel.avatarStyle) { + ForEach(UserDefaults.AvatarStyle.allCases, id: \.self) { preference in + Text(preference.text) + } + } label: { + Text(L10n.Scene.Settings.Display.Text.avatarStyle) + } + } + +} diff --git a/TwidereX/Scene/Setting/DisplayPreference/DisplayPreferenceViewController.swift b/TwidereX/Scene/Setting/DisplayPreference/DisplayPreferenceViewController.swift index b43ff9fa..50afb7d8 100644 --- a/TwidereX/Scene/Setting/DisplayPreference/DisplayPreferenceViewController.swift +++ b/TwidereX/Scene/Setting/DisplayPreference/DisplayPreferenceViewController.swift @@ -16,7 +16,8 @@ final class DisplayPreferenceViewController: UIViewController, NeedsDependency { weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } var disposeBag = Set() - let viewModel = DisplayPreferenceViewModel() + var viewModel: DisplayPreferenceViewModel! + private(set) lazy var displayPreferenceView = DisplayPreferenceView(viewModel: viewModel) private(set) lazy var tableView: UITableView = { let tableView = ControlContainableTableView(frame: .zero, style: .insetGrouped) @@ -40,61 +41,77 @@ extension DisplayPreferenceViewController { super.viewDidLoad() title = L10n.Scene.Settings.Display.title + viewModel.viewSize = view.frame.size - tableView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(tableView) + let hostingViewController = UIHostingController(rootView: displayPreferenceView) + addChild(hostingViewController) + hostingViewController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(hostingViewController.view) NSLayoutConstraint.activate([ - tableView.topAnchor.constraint(equalTo: view.topAnchor), - tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + hostingViewController.view.topAnchor.constraint(equalTo: view.topAnchor), + hostingViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + hostingViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) - - tableView.delegate = self - tableView.dataSource = viewModel - -// tableView.addGestureRecognizer(textFontSizeSliderPanGestureRecognizer) -// textFontSizeSliderPanGestureRecognizer.addTarget(self, action: #selector(DisplayPreferenceViewController.sliderPanGestureRecoginzerHandler(_:))) -// textFontSizeSliderPanGestureRecognizer.delegate = self } - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) + public override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() - tableView.deselectRow(with: transitionCoordinator, animated: animated) + viewModel.viewLayoutFrame.update(view: view) + if viewModel.viewSize != view.frame.size { + viewModel.viewSize = view.frame.size + } } - -} - -// MARK: - UITableViewDelegate -extension DisplayPreferenceViewController: UITableViewDelegate { - func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - let sectionData = viewModel.sections[section] - let header = sectionData.header - let headerView = TableViewSectionTextHeaderView() - headerView.label.text = header - return headerView + override func viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + + viewModel.viewLayoutFrame.update(view: view) } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let section = viewModel.sections[indexPath.section] - let setting = section.settings[indexPath.row] - - switch setting { - case .avatarStyle(let avatarStyle): - UserDefaults.shared.avatarStyle = avatarStyle - tableView.deselectRow(at: indexPath, animated: true) - default: - break + coordinator.animate {[weak self] _ in + guard let self = self else { return } + self.viewModel.viewLayoutFrame.update(view: self.view) + } completion: { _ in + // do nothing } } } -extension DisplayPreferenceViewController { - - @objc private func sliderPanGestureRecoginzerHandler(_ sender: UIPanGestureRecognizer) { +// MARK: - UITableViewDelegate +//extension DisplayPreferenceViewController: UITableViewDelegate { +// +// func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { +// let sectionData = viewModel.sections[section] +// let header = sectionData.header +// let headerView = TableViewSectionTextHeaderView() +// headerView.label.text = header +// return headerView +// } +// +// func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { +// let section = viewModel.sections[indexPath.section] +// let setting = section.settings[indexPath.row] +// +// switch setting { +// case .avatarStyle(let avatarStyle): +// UserDefaults.shared.avatarStyle = avatarStyle +// tableView.deselectRow(at: indexPath, animated: true) +// default: +// break +// } +// } +// +//} + +//extension DisplayPreferenceViewController { +// +// @objc private func sliderPanGestureRecoginzerHandler(_ sender: UIPanGestureRecognizer) { // let slider = viewModel.fontSizeSlideTableViewCell.slider // guard slider.isUserInteractionEnabled else { return } // @@ -109,13 +126,13 @@ extension DisplayPreferenceViewController { // let index = max(0, min(UserDefaults.contentSizeCategory.count - 1, Int(roundValue))) // let customContentSizeCatagory = UserDefaults.contentSizeCategory[index] // viewModel.customContentSizeCatagory.value = customContentSizeCatagory - } - -} +// } +// +//} // MARK: - UIGestureRecognizerDelegate -extension DisplayPreferenceViewController: UIGestureRecognizerDelegate { - +//extension DisplayPreferenceViewController: UIGestureRecognizerDelegate { +// // func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { // if gestureRecognizer === textFontSizeSliderPanGestureRecognizer { // return true @@ -135,5 +152,5 @@ extension DisplayPreferenceViewController: UIGestureRecognizerDelegate { // // return true // } - -} +// +//} diff --git a/TwidereX/Scene/Setting/DisplayPreference/DisplayPreferenceViewModel.swift b/TwidereX/Scene/Setting/DisplayPreference/DisplayPreferenceViewModel.swift index cd59f84b..f960bd21 100644 --- a/TwidereX/Scene/Setting/DisplayPreference/DisplayPreferenceViewModel.swift +++ b/TwidereX/Scene/Setting/DisplayPreference/DisplayPreferenceViewModel.swift @@ -9,129 +9,64 @@ import os.log import UIKit import Combine -import AppShared -import TwidereAsset -import TwidereLocalization -import TwidereUI import TwitterMeta -final class DisplayPreferenceViewModel: NSObject { +final class DisplayPreferenceViewModel: ObservableObject { var disposeBag = Set() + + // MARK: - layout + @Published var viewSize: CGSize = .zero + @Published public var viewLayoutFrame = ViewLayoutFrame() // input -// let customContentSizeCatagory: CurrentValueSubject - - // output - let sections: [Section] = [ - Section(header: L10n.Scene.Settings.Display.SectionHeader.preview, settings: [.preview]), - // Section(header: L10n.Scene.Settings.Display.SectionHeader.text, settings: [ - // .useTheSystemFontSizeSwitch, - // .fontSizeSlider, - // ]), - Section(header: L10n.Scene.Settings.Display.Text.avatarStyle, settings: [ - .avatarStyle(.circle), - .avatarStyle(.roundedSquare), - ]), - ] - let fontSizeSlideTableViewCell = TableSlideTableViewCell() - - override init() { -// customContentSizeCatagory = CurrentValueSubject(UserDefaults.shared.customContentSizeCatagory) - super.init() + let authContext: AuthContext + lazy var statusViewModel = StatusView.ViewModel.prototype(viewLayoutFramePublisher: $viewLayoutFrame) -// let feedbackGenerator = UIImpactFeedbackGenerator(style: .light) -// customContentSizeCatagory -// .dropFirst() -// .removeDuplicates() -// .sink { customContentSizeCatagory in -// feedbackGenerator.impactOccurred() -// UserDefaults.shared.customContentSizeCatagory = customContentSizeCatagory -// } -// .store(in: &disposeBag) - } - + // avatar + @Published var avatarStyle = UserDefaults.shared.avatarStyle -} - -extension DisplayPreferenceViewModel { - - enum Setting { - case preview - -// case useTheSystemFontSizeSwitch -// case fontSizeSlider - - // Avatar Style - case avatarStyle(UserDefaults.AvatarStyle) - - case dateFormat - } + // Translation + @Published var translateButtonPreference = UserDefaults.shared.translateButtonPreference + @Published var translationServicePreference = UserDefaults.shared.translationServicePreference - struct Section { - let header: String - let settings: [Setting] - } + // output + @Published var authenticationContext: AuthenticationContext? -} - -// MARK: - UITableViewDataSource -extension DisplayPreferenceViewModel: UITableViewDataSource { - func numberOfSections(in tableView: UITableView) -> Int { - return sections.count - } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return sections[section].settings.count - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell: UITableViewCell + init(authContext: AuthContext) { + self.authContext = authContext + // end init - let section = sections[indexPath.section] - switch section.settings[indexPath.row] { - case .preview: - let _cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell - DisplayPreferenceViewModel.configure(cell: _cell) - cell = _cell - case .avatarStyle(let avatarStyle): - let _cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TableViewCheckmarkTableViewCell.self), for: indexPath) as! TableViewCheckmarkTableViewCell - let metaContent = Meta.convert(from: .plaintext(string: avatarStyle.text)) - _cell.primaryTextLabel.configure(content: metaContent) - UserDefaults.shared - .observe(\.avatarStyle, options: [.initial, .new]) { defaults, _ in - _cell.accessoryType = avatarStyle == defaults.avatarStyle ? .checkmark : .none - } - .store(in: &_cell.observations) - cell = _cell - case .dateFormat: - let _cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TableViewEntryTableViewCell.self), for: indexPath) as! TableViewEntryTableViewCell - _cell.iconImageView.isHidden = true - let metaContent = Meta.convert(from: .plaintext(string: L10n.Scene.Settings.Display.SectionHeader.dateFormat)) - _cell.primaryTextLabel.configure(content: metaContent) - cell = _cell - } - return cell - } - -} - -extension DisplayPreferenceViewModel { - - static func configure(cell: StatusTableViewCell) { - cell.selectionStyle = .none + // avatar style + UserDefaults.shared.publisher(for: \.avatarStyle) + .removeDuplicates() + .assign(to: &$avatarStyle) + $avatarStyle + .sink { avatarStyle in + UserDefaults.shared.avatarStyle = avatarStyle + } + .store(in: &disposeBag) + + // Translation + UserDefaults.shared.publisher(for: \.translateButtonPreference) + .removeDuplicates() + .assign(to: &$translateButtonPreference) + $translateButtonPreference + .sink { preference in + UserDefaults.shared.translateButtonPreference = preference + } + .store(in: &disposeBag) - cell.statusView.viewModel.authorAvatarImage = Asset.Scene.Preference.twidereAvatar.image - cell.statusView.viewModel.authorName = PlaintextMetaContent(string: "Twidere") - cell.statusView.viewModel.authorUsername = "TwidereProject" - cell.statusView.viewModel.protected = false - cell.statusView.viewModel.timestamp = Date() - cell.statusView.viewModel.dateTimeProvider = DateTimeSwiftProvider() - let content = TwitterContent(content: L10n.Scene.Settings.Display.Preview.thankForUsingTwidereX) - cell.statusView.viewModel.content = TwitterMetaContent.convert(content: content, urlMaximumLength: 16, twitterTextProvider: OfficialTwitterTextProvider()) - cell.statusView.isUserInteractionEnabled = false - cell.separator.isHidden = true + // Translation service + UserDefaults.shared.publisher(for: \.translationServicePreference) + .removeDuplicates() + .assign(to: &$translationServicePreference) + $translationServicePreference + .sink { preference in + UserDefaults.shared.translationServicePreference = preference + } + .store(in: &disposeBag) } } diff --git a/TwidereX/Scene/Setting/AppearancePreference/TranslateButtonPreferenceView.swift b/TwidereX/Scene/Setting/DisplayPreference/Translation/TranslateButtonPreferenceView.swift similarity index 98% rename from TwidereX/Scene/Setting/AppearancePreference/TranslateButtonPreferenceView.swift rename to TwidereX/Scene/Setting/DisplayPreference/Translation/TranslateButtonPreferenceView.swift index 81f5b601..951e90b4 100644 --- a/TwidereX/Scene/Setting/AppearancePreference/TranslateButtonPreferenceView.swift +++ b/TwidereX/Scene/Setting/DisplayPreference/Translation/TranslateButtonPreferenceView.swift @@ -9,7 +9,6 @@ import os.log import Foundation import SwiftUI -import TwidereCommon struct TranslateButtonPreferenceView: View { diff --git a/TwidereX/Scene/Setting/AppearancePreference/TranslationServicePreferenceView.swift b/TwidereX/Scene/Setting/DisplayPreference/Translation/TranslationServicePreferenceView.swift similarity index 98% rename from TwidereX/Scene/Setting/AppearancePreference/TranslationServicePreferenceView.swift rename to TwidereX/Scene/Setting/DisplayPreference/Translation/TranslationServicePreferenceView.swift index 48835457..85bff850 100644 --- a/TwidereX/Scene/Setting/AppearancePreference/TranslationServicePreferenceView.swift +++ b/TwidereX/Scene/Setting/DisplayPreference/Translation/TranslationServicePreferenceView.swift @@ -9,7 +9,6 @@ import os.log import Foundation import SwiftUI -import TwidereCommon struct TranslationServicePreferenceView: View { diff --git a/TwidereX/Scene/Setting/List/SettingListView.swift b/TwidereX/Scene/Setting/List/SettingListView.swift index 97633ad4..f43ea315 100644 --- a/TwidereX/Scene/Setting/List/SettingListView.swift +++ b/TwidereX/Scene/Setting/List/SettingListView.swift @@ -9,7 +9,6 @@ import CoreData import CoreDataStack import SwiftUI -import TwidereUI struct TextCaseEraseStyle: ViewModifier { func body(content: Content) -> some View { @@ -25,10 +24,11 @@ struct TextCaseEraseStyle: ViewModifier { enum SettingListEntryType: Hashable { case account - case appearance + case behaviors case display case layout case webBrowser + case appIcon case about #if DEBUG @@ -38,10 +38,11 @@ enum SettingListEntryType: Hashable { var image: Image { switch self { case .account: return Image(systemName: "person") - case .appearance: return Image(uiImage: Asset.ObjectTools.clothes.image) + case .behaviors: return Image(uiImage: Asset.Arrows.arrowRampRight.image) case .display: return Image(uiImage: Asset.TextFormatting.textHeaderRedaction.image) case .layout: return Image(uiImage: Asset.sidebarLeft.image) case .webBrowser: return Image(uiImage: Asset.window.image) + case .appIcon: return Image(uiImage: Asset.Logo.twidere.image) case .about: return Image(uiImage: Asset.Indices.infoCircle.image) #if DEBUG case .developer: return Image(systemName: "hammer") @@ -51,11 +52,12 @@ enum SettingListEntryType: Hashable { var title: String { switch self { - case .account: return "Account" // TODO: i18n - case .appearance: return L10n.Scene.Settings.Appearance.title + case .account: return L10n.Scene.Settings.SectionHeader.account + case .behaviors: return L10n.Scene.Settings.Behaviors.title case .display: return L10n.Scene.Settings.Display.title case .layout: return "Layout" case .webBrowser: return "Web Browser" + case .appIcon: return L10n.Scene.Settings.Appearance.appIcon case .about: return L10n.Scene.Settings.About.title #if DEBUG case .developer: return "Developer" @@ -83,11 +85,8 @@ struct SettingListView: View { @ViewBuilder var accountView: some View { - if let user = viewModel.user { - UserContentView(viewModel: .init( - user: user, - accessoryType: .disclosureIndicator - )) + if let userViewModel = viewModel.userViewModel { + UserView(viewModel: userViewModel) } else { EmptyView() } @@ -95,7 +94,7 @@ struct SettingListView: View { static let generalSection: [SettingListEntry] = { let types: [SettingListEntryType] = [ - .appearance, + .behaviors, .display, // .layout, // .webBrowser @@ -105,6 +104,20 @@ struct SettingListView: View { } }() + var appIconRow: some View { + Button { + + } label: { + HStack { + Text(L10n.Scene.Settings.Appearance.appIcon) + Spacer() + Image(uiImage: UIImage(named: "\(viewModel.alternateIconNamePreference.iconName)") ?? UIImage()) + .cornerRadius(4) + } + } + .tint(Color(uiColor: .label)) + } + static let aboutSection: [SettingListEntry] = { let types: [SettingListEntryType] = [ .about, @@ -127,19 +140,19 @@ struct SettingListView: View { var body: some View { List { - Section(header: Text(verbatim: "Account")) { + // Account Section + Section { Button { viewModel.settingListEntryPublisher.send(SettingListView.accountListEntry) } label: { accountView } + } header: { + Text(verbatim: L10n.Scene.Settings.SectionHeader.account) + .textCase(nil) } - Section( - // grouped tableView get header padding since iOS 15. - // no more top padding manually - // seealso: 'UITableView.sectionHeaderTopPadding' - header: Text(verbatim: L10n.Scene.Settings.SectionHeader.general) - ) { + // General Section + Section { ForEach(SettingListView.generalSection) { entry in Button(action: { viewModel.settingListEntryPublisher.send(entry) @@ -148,9 +161,20 @@ struct SettingListView: View { .foregroundColor(Color(.label)) }) } + } header: { + Text(verbatim: L10n.Scene.Settings.SectionHeader.general) + .textCase(nil) } - .modifier(TextCaseEraseStyle()) - Section(header: Text(verbatim: L10n.Scene.Settings.SectionHeader.about)) { + // App Icon Section + Section { + NavigationLink { + AppIconPreferenceView() + } label: { + appIconRow + } + } + // About Section + Section { ForEach(SettingListView.aboutSection) { entry in Button(action: { viewModel.settingListEntryPublisher.send(entry) @@ -159,8 +183,10 @@ struct SettingListView: View { .foregroundColor(Color(.label)) }) } + } header: { + Text(verbatim: L10n.Scene.Settings.SectionHeader.about) + .textCase(nil) } - .modifier(TextCaseEraseStyle()) #if DEBUG Section { ForEach(SettingListView.developerSection) { entry in @@ -172,7 +198,6 @@ struct SettingListView: View { }) } } - .modifier(TextCaseEraseStyle()) #endif } .listStyle(InsetGroupedListStyle()) @@ -185,15 +210,17 @@ struct SettingListView: View { struct SettingListView_Previews: PreviewProvider { static var previews: some View { Group { - SettingListView(viewModel: SettingListViewModel( - context: .shared, - auth: nil - )) - SettingListView(viewModel: SettingListViewModel( - context: .shared, - auth: nil - )) - .preferredColorScheme(.dark) + if let authContext = AuthContext.mock(context: .shared) { + SettingListView(viewModel: SettingListViewModel( + context: .shared, + authContext: authContext + )) + SettingListView(viewModel: SettingListViewModel( + context: .shared, + authContext: authContext + )) + .preferredColorScheme(.dark) + } } } } diff --git a/TwidereX/Scene/Setting/List/SettingListViewController.swift b/TwidereX/Scene/Setting/List/SettingListViewController.swift index d2c59489..d8a4dd6f 100644 --- a/TwidereX/Scene/Setting/List/SettingListViewController.swift +++ b/TwidereX/Scene/Setting/List/SettingListViewController.swift @@ -49,13 +49,12 @@ extension SettingListViewController { guard let self = self else { return } switch entry.type { case .account: - // FIXME: - guard let authenticationContext = self.context.authenticationService.activeAuthenticationContext else { return } - guard let user = self.viewModel.user else { return } + let authContext = self.viewModel.authContext + guard let user = authContext.authenticationContext.user(in: self.context.managedObjectContext) else { return } let accountPreferenceViewModel = AccountPreferenceViewModel( context: self.context, - auth: .init(authenticationContext: authenticationContext), + authContext: self.viewModel.authContext, user: user ) self.coordinator.present( @@ -63,19 +62,29 @@ extension SettingListViewController { from: self, transition: .show ) - case .appearance: - self.coordinator.present(scene: .appearancePreference, from: self, transition: .show) + case .behaviors: + let behaviorsPreferenceViewModel = BehaviorsPreferenceViewModel(context: self.context) + self.coordinator.present( + scene: .behaviorsPreference(viewModel: behaviorsPreferenceViewModel), + from: self, + transition: .show + ) case .display: - self.coordinator.present(scene: .displayPreference, from: self, transition: .show) + let displayPreferenceViewModel = DisplayPreferenceViewModel(authContext: self.viewModel.authContext) + self.coordinator.present(scene: .displayPreference(viewModel: displayPreferenceViewModel), from: self, transition: .show) case .layout: break case .webBrowser: break + case .appIcon: + break case .about: - self.coordinator.present(scene: .about, from: self, transition: .show) + let aboutViewModel = AboutViewModel(authContext: self.viewModel.authContext) + self.coordinator.present(scene: .about(viewModel: aboutViewModel), from: self, transition: .show) #if DEBUG case .developer: - self.coordinator.present(scene: .developer, from: self, transition: .show) + let developerViewModel = DeveloperViewModel(authContext: self.viewModel.authContext) + self.coordinator.present(scene: .developer(viewModel: developerViewModel), from: self, transition: .show) #endif } } diff --git a/TwidereX/Scene/Setting/List/SettingListViewModel.swift b/TwidereX/Scene/Setting/List/SettingListViewModel.swift index a8820053..3e951010 100644 --- a/TwidereX/Scene/Setting/List/SettingListViewModel.swift +++ b/TwidereX/Scene/Setting/List/SettingListViewModel.swift @@ -22,25 +22,32 @@ final class SettingListViewModel: ObservableObject { // input let context: AppContext - let auth: AuthContext? + let authContext: AuthContext // output let settingListEntryPublisher = PassthroughSubject() // account - @Published var user: UserObject? - + @Published var userViewModel: UserView.ViewModel? + + // App Icon + @Published var alternateIconNamePreference = UserDefaults.shared.alternateIconNamePreference + init( context: AppContext, - auth: AuthContext? + authContext: AuthContext ) { self.context = context - self.auth = auth + self.authContext = authContext // end init Task { await setupAccountSource() } + + // App Icon + UserDefaults.shared.publisher(for: \.alternateIconNamePreference) + .assign(to: &$alternateIconNamePreference) } } @@ -48,6 +55,12 @@ final class SettingListViewModel: ObservableObject { extension SettingListViewModel { @MainActor func setupAccountSource() async { - user = auth?.authenticationContext.user(in: context.managedObjectContext) + guard let user = authContext.authenticationContext.user(in: context.managedObjectContext) else { return } + userViewModel = UserView.ViewModel( + user: user, + authContext: authContext, + kind: .settingAccountSection, + delegate: nil + ) } } diff --git a/TwidereX/Scene/Setting/PushNotificationScratch/PushNotificationScratchViewModel.swift b/TwidereX/Scene/Setting/PushNotificationScratch/PushNotificationScratchViewModel.swift index 86891121..21e26965 100644 --- a/TwidereX/Scene/Setting/PushNotificationScratch/PushNotificationScratchViewModel.swift +++ b/TwidereX/Scene/Setting/PushNotificationScratch/PushNotificationScratchViewModel.swift @@ -6,29 +6,65 @@ // Copyright © 2022 Twidere. All rights reserved. // +import os.log import Foundation import CoreData import CoreDataStack import TwidereCore -final class PushNotificationScratchViewModel: ObservableObject { +final class PushNotificationScratchViewModel: NSObject, ObservableObject { // input let context: AppContext + let authenticationIndexFetchedResultsController: NSFetchedResultsController @Published var isRandomNotification = true @Published var notificationID = "" - @Published var accounts: [UserObject] - @Published var activeAccountIndex: Int = 0 - // output - + @Published var accounts: [UserObject] = [] + @Published var activeAccountIndex: Int = 0 + init(context: AppContext) { self.context = context + self.authenticationIndexFetchedResultsController = { + let fetchRequest = AuthenticationIndex.sortedFetchRequest + fetchRequest.returnsObjectsAsFaults = false + fetchRequest.fetchBatchSize = 20 + let controller = NSFetchedResultsController( + fetchRequest: fetchRequest, + managedObjectContext: context.managedObjectContext, + sectionNameKeyPath: nil, + cacheName: nil + ) + return controller + }() + super.init() // end init - accounts = context.authenticationService.authenticationIndexes.compactMap { $0.user } + authenticationIndexFetchedResultsController.delegate = self + try? authenticationIndexFetchedResultsController.performFetch() } } + +// MARK: - NSFetchedResultsControllerDelegate +extension PushNotificationScratchViewModel: NSFetchedResultsControllerDelegate { + + public func controllerWillChangeContent(_ controller: NSFetchedResultsController) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + + public func controllerDidChangeContent(_ controller: NSFetchedResultsController) { + switch controller { + case authenticationIndexFetchedResultsController: + let authenticationIndexes = authenticationIndexFetchedResultsController.fetchedObjects ?? [] + accounts = authenticationIndexes.compactMap { authenticationIndex in + authenticationIndex.user + } + default: + assertionFailure() + } + } + +} diff --git a/TwidereX/Scene/Share/View/Button/AvatarBarButtonItem+ViewModel.swift b/TwidereX/Scene/Share/View/Button/AvatarBarButtonItem+ViewModel.swift index b45749cd..635021c7 100644 --- a/TwidereX/Scene/Share/View/Button/AvatarBarButtonItem+ViewModel.swift +++ b/TwidereX/Scene/Share/View/Button/AvatarBarButtonItem+ViewModel.swift @@ -10,7 +10,6 @@ import UIKit import Combine import TwidereCore import CoreDataStack -import TwidereUI extension AvatarBarButtonItem { public class ViewModel: ObservableObject { diff --git a/TwidereX/Scene/Share/View/Button/AvatarBarButtonItem.swift b/TwidereX/Scene/Share/View/Button/AvatarBarButtonItem.swift index 3f99ee08..851a8656 100644 --- a/TwidereX/Scene/Share/View/Button/AvatarBarButtonItem.swift +++ b/TwidereX/Scene/Share/View/Button/AvatarBarButtonItem.swift @@ -9,7 +9,6 @@ import os.log import UIKit import Combine -import TwidereUI public protocol AvatarBarButtonItemDelegate: AnyObject { func avatarBarButtonItem(_ barButtonItem: AvatarBarButtonItem, didLongPressed sender: UILongPressGestureRecognizer) diff --git a/TwidereX/Scene/Share/View/Button/FollowActionButton.swift b/TwidereX/Scene/Share/View/Button/FollowActionButton.swift index 030b52c4..cbdcd265 100644 --- a/TwidereX/Scene/Share/View/Button/FollowActionButton.swift +++ b/TwidereX/Scene/Share/View/Button/FollowActionButton.swift @@ -7,6 +7,7 @@ // import UIKit +import TwidereCore final class FollowActionButton: UIButton { diff --git a/TwidereX/Scene/Share/View/CollectionViewCell/CoverFlowStackMediaCollectionCell+ViewModel.swift b/TwidereX/Scene/Share/View/CollectionViewCell/CoverFlowStackMediaCollectionCell+ViewModel.swift index 997b76c4..210b2dba 100644 --- a/TwidereX/Scene/Share/View/CollectionViewCell/CoverFlowStackMediaCollectionCell+ViewModel.swift +++ b/TwidereX/Scene/Share/View/CollectionViewCell/CoverFlowStackMediaCollectionCell+ViewModel.swift @@ -14,24 +14,24 @@ extension CoverFlowStackMediaCollectionCell { final class ViewModel: ObservableObject { var disposeBag = Set() - @Published var mediaViewConfiguration: MediaView.Configuration? + @Published var mediaViewModel: MediaView.ViewModel? } } extension CoverFlowStackMediaCollectionCell.ViewModel { func bind(cell: CoverFlowStackMediaCollectionCell) { - $mediaViewConfiguration - .sink { configuration in - guard let configuration = configuration else { return } - cell.mediaView.setup(configuration: configuration) - } - .store(in: &disposeBag) +// $mediaViewConfiguration +// .sink { configuration in +// guard let configuration = configuration else { return } +// cell.mediaView.setup(configuration: configuration) +// } +// .store(in: &disposeBag) } } extension CoverFlowStackMediaCollectionCell { - func configure(configuration: MediaView.Configuration) { - viewModel.mediaViewConfiguration = configuration + func configure(configuration: MediaView.ViewModel) { +// viewModel.mediaViewConfiguration = configuration } } diff --git a/TwidereX/Scene/Share/View/CollectionViewCell/CoverFlowStackMediaCollectionCell.swift b/TwidereX/Scene/Share/View/CollectionViewCell/CoverFlowStackMediaCollectionCell.swift index 9e859c53..a77baa50 100644 --- a/TwidereX/Scene/Share/View/CollectionViewCell/CoverFlowStackMediaCollectionCell.swift +++ b/TwidereX/Scene/Share/View/CollectionViewCell/CoverFlowStackMediaCollectionCell.swift @@ -18,12 +18,12 @@ final class CoverFlowStackMediaCollectionCell: UICollectionViewCell { return viewModel }() - let mediaView = MediaView() +// let mediaView = MediaView() override func prepareForReuse() { super.prepareForReuse() - mediaView.prepareForReuse() +// mediaView.prepareForReuse() } override init(frame: CGRect) { super.init(frame: frame) @@ -40,21 +40,21 @@ final class CoverFlowStackMediaCollectionCell: UICollectionViewCell { extension CoverFlowStackMediaCollectionCell { private func _init() { - contentView.layer.masksToBounds = true - contentView.layer.cornerRadius = MediaView.cornerRadius - contentView.layer.cornerCurve = .continuous - - mediaView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(mediaView) - NSLayoutConstraint.activate([ - mediaView.topAnchor.constraint(equalTo: contentView.topAnchor), - mediaView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - mediaView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - mediaView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - ]) - - // delegate user interactive to collection view - mediaView.isUserInteractionEnabled = false +// contentView.layer.masksToBounds = true +// contentView.layer.cornerRadius = MediaView.cornerRadius +// contentView.layer.cornerCurve = .continuous +// +// mediaView.translatesAutoresizingMaskIntoConstraints = false +// contentView.addSubview(mediaView) +// NSLayoutConstraint.activate([ +// mediaView.topAnchor.constraint(equalTo: contentView.topAnchor), +// mediaView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), +// mediaView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), +// mediaView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), +// ]) +// +// // delegate user interactive to collection view +// mediaView.isUserInteractionEnabled = false } } diff --git a/TwidereX/Scene/Share/View/CollectionViewCell/StatusCollectionViewCell.swift b/TwidereX/Scene/Share/View/CollectionViewCell/StatusCollectionViewCell.swift index 50bb9172..a930996f 100644 --- a/TwidereX/Scene/Share/View/CollectionViewCell/StatusCollectionViewCell.swift +++ b/TwidereX/Scene/Share/View/CollectionViewCell/StatusCollectionViewCell.swift @@ -13,12 +13,12 @@ final class StatusCollectionViewCell: UICollectionViewCell { var disposeBag = Set() - private(set) lazy var statusView = StatusView() +// private(set) lazy var statusView = StatusView() override func prepareForReuse() { super.prepareForReuse() - statusView.prepareForReuse() +// statusView.prepareForReuse() disposeBag.removeAll() } @@ -37,14 +37,14 @@ final class StatusCollectionViewCell: UICollectionViewCell { extension StatusCollectionViewCell { private func _init() { - statusView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(statusView) - NSLayoutConstraint.activate([ - statusView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12), - statusView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), - statusView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), - statusView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - ]) +// statusView.translatesAutoresizingMaskIntoConstraints = false +// contentView.addSubview(statusView) +// NSLayoutConstraint.activate([ +// statusView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12), +// statusView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), +// statusView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), +// statusView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), +// ]) } } diff --git a/TwidereX/Scene/Share/View/CollectionViewCell/StatusMediaGalleryCollectionCell+ViewModel.swift b/TwidereX/Scene/Share/View/CollectionViewCell/StatusMediaGalleryCollectionCell+ViewModel.swift index 232be892..1da539e3 100644 --- a/TwidereX/Scene/Share/View/CollectionViewCell/StatusMediaGalleryCollectionCell+ViewModel.swift +++ b/TwidereX/Scene/Share/View/CollectionViewCell/StatusMediaGalleryCollectionCell+ViewModel.swift @@ -15,7 +15,7 @@ extension StatusMediaGalleryCollectionCell { final class ViewModel: ObservableObject { var disposeBag = Set() - @Published var mediaViewConfigurations: [MediaView.Configuration] = [] + @Published var mediaViewViewModels: [MediaView.ViewModel] = [] // input @Published public var isMediaSensitive: Bool = false @@ -57,90 +57,90 @@ extension StatusMediaGalleryCollectionCell.ViewModel { } func bind(cell: StatusMediaGalleryCollectionCell) { - $mediaViewConfigurations - .sink { [weak self] configurations in - guard let self = self else { return } - - switch configurations.count { - case 0: - cell.mediaView.isHidden = true - cell.collectionView.isHidden = true - case 1: - cell.mediaView.setup(configuration: configurations[0]) - cell.mediaView.isHidden = false - cell.collectionView.isHidden = true - default: - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.main]) - let items: [CoverFlowStackItem] = configurations.map { .media(configuration: $0) } - snapshot.appendItems(items, toSection: .main) - cell.diffableDataSource?.applySnapshotUsingReloadData(snapshot) - cell.mediaView.isHidden = true - cell.collectionView.isHidden = false - } - } - .store(in: &disposeBag) - $isSensitiveToggleButtonDisplay - .sink { isDisplay in - cell.sensitiveToggleButtonBlurVisualEffectView.isHidden = !isDisplay - } - .store(in: &disposeBag) - $isContentWarningOverlayDisplay - .sink { isDisplay in - assert(Thread.isMainThread) - - let isDisplay = isDisplay ?? false - let withAnimation = self.isContentWarningOverlayDisplay != nil - - if withAnimation { - UIView.animate(withDuration: 0.33, delay: 0, options: .curveEaseInOut) { - cell.contentWarningOverlayView.blurVisualEffectView.alpha = isDisplay ? 1 : 0 - } - } else { - cell.contentWarningOverlayView.blurVisualEffectView.alpha = isDisplay ? 1 : 0 - } - - cell.contentWarningOverlayView.isUserInteractionEnabled = isDisplay - cell.contentWarningOverlayView.tapGestureRecognizer.isEnabled = isDisplay - } - .store(in: &disposeBag) +// $mediaViewConfigurations +// .sink { [weak self] configurations in +// guard let self = self else { return } +// +// switch configurations.count { +// case 0: +// cell.mediaView.isHidden = true +// cell.collectionView.isHidden = true +// case 1: +// cell.mediaView.setup(configuration: configurations[0]) +// cell.mediaView.isHidden = false +// cell.collectionView.isHidden = true +// default: +// var snapshot = NSDiffableDataSourceSnapshot() +// snapshot.appendSections([.main]) +// let items: [CoverFlowStackItem] = configurations.map { .media(configuration: $0) } +// snapshot.appendItems(items, toSection: .main) +// cell.diffableDataSource?.applySnapshotUsingReloadData(snapshot) +// cell.mediaView.isHidden = true +// cell.collectionView.isHidden = false +// } +// } +// .store(in: &disposeBag) +// $isSensitiveToggleButtonDisplay +// .sink { isDisplay in +// cell.sensitiveToggleButtonBlurVisualEffectView.isHidden = !isDisplay +// } +// .store(in: &disposeBag) +// $isContentWarningOverlayDisplay +// .sink { isDisplay in +// assert(Thread.isMainThread) +// +// let isDisplay = isDisplay ?? false +// let withAnimation = self.isContentWarningOverlayDisplay != nil +// +// if withAnimation { +// UIView.animate(withDuration: 0.33, delay: 0, options: .curveEaseInOut) { +// cell.contentWarningOverlayView.blurVisualEffectView.alpha = isDisplay ? 1 : 0 +// } +// } else { +// cell.contentWarningOverlayView.blurVisualEffectView.alpha = isDisplay ? 1 : 0 +// } +// +// cell.contentWarningOverlayView.isUserInteractionEnabled = isDisplay +// cell.contentWarningOverlayView.tapGestureRecognizer.isEnabled = isDisplay +// } +// .store(in: &disposeBag) } } extension StatusMediaGalleryCollectionCell { func configure(status object: StatusObject) { - switch object { - case .twitter(let status): - configure(twitterStatus: status) - case .mastodon(let status): - configure(mastodonStatus: status) - } +// switch object { +// case .twitter(let status): +// configure(twitterStatus: status) +// case .mastodon(let status): +// configure(mastodonStatus: status) +// } } - private func configure(twitterStatus status: TwitterStatus) { - let status = status.repost ?? status - - viewModel.resetContentWarningOverlay() - viewModel.isMediaSensitive = false - viewModel.isMediaSensitiveToggled = false - viewModel.isMediaSensitiveSwitchable = false - viewModel.mediaViewConfigurations = MediaView.configuration(twitterStatus: status) - } - - private func configure(mastodonStatus status: MastodonStatus) { - let status = status.repost ?? status - - viewModel.resetContentWarningOverlay() - viewModel.isMediaSensitiveSwitchable = true - viewModel.mediaViewConfigurations = MediaView.configuration(mastodonStatus: status) - status.publisher(for: \.isMediaSensitive) - .receive(on: DispatchQueue.main) - .assign(to: \.isMediaSensitive, on: viewModel) - .store(in: &disposeBag) - status.publisher(for: \.isMediaSensitiveToggled) - .receive(on: DispatchQueue.main) - .assign(to: \.isMediaSensitiveToggled, on: viewModel) - .store(in: &disposeBag) - } +// private func configure(twitterStatus status: TwitterStatus) { +// let status = status.repost ?? status +// +// viewModel.resetContentWarningOverlay() +// viewModel.isMediaSensitive = false +// viewModel.isMediaSensitiveToggled = false +// viewModel.isMediaSensitiveSwitchable = false +// viewModel.mediaViewConfigurations = MediaView.configuration(twitterStatus: status) +// } +// +// private func configure(mastodonStatus status: MastodonStatus) { +// let status = status.repost ?? status +// +// viewModel.resetContentWarningOverlay() +// viewModel.isMediaSensitiveSwitchable = true +// viewModel.mediaViewConfigurations = MediaView.configuration(mastodonStatus: status) +// status.publisher(for: \.isMediaSensitive) +// .receive(on: DispatchQueue.main) +// .assign(to: \.isMediaSensitive, on: viewModel) +// .store(in: &disposeBag) +// status.publisher(for: \.isMediaSensitiveToggled) +// .receive(on: DispatchQueue.main) +// .assign(to: \.isMediaSensitiveToggled, on: viewModel) +// .store(in: &disposeBag) +// } } diff --git a/TwidereX/Scene/Share/View/CollectionViewCell/StatusMediaGalleryCollectionCell.swift b/TwidereX/Scene/Share/View/CollectionViewCell/StatusMediaGalleryCollectionCell.swift index e7428307..cee5be77 100644 --- a/TwidereX/Scene/Share/View/CollectionViewCell/StatusMediaGalleryCollectionCell.swift +++ b/TwidereX/Scene/Share/View/CollectionViewCell/StatusMediaGalleryCollectionCell.swift @@ -10,71 +10,23 @@ import os.log import UIKit import Combine import CoverFlowStackCollectionViewLayout -import TwidereUI protocol StatusMediaGalleryCollectionCellDelegate: AnyObject { - func statusMediaGalleryCollectionCell(_ cell: StatusMediaGalleryCollectionCell, coverFlowCollectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) - func statusMediaGalleryCollectionCell(_ cell: StatusMediaGalleryCollectionCell, toggleContentWarningOverlayViewDisplay contentWarningOverlayView: ContentWarningOverlayView) + func statusMediaGalleryCollectionCell(_ cell: StatusMediaGalleryCollectionCell, mediaStackContainerViewModel: MediaStackContainerView.ViewModel, didSelectMediaView mediaViewModel: MediaView.ViewModel) + // func statusMediaGalleryCollectionCell(_ cell: StatusMediaGalleryCollectionCell, toggleContentWarningOverlayViewDisplay contentWarningOverlayView: ContentWarningOverlayView) } final class StatusMediaGalleryCollectionCell: UICollectionViewCell { let logger = Logger(subsystem: "StatusMediaGalleryCollectionCell", category: "Cell") - + weak var delegate: StatusMediaGalleryCollectionCellDelegate? - - var disposeBag = Set() - private(set) lazy var viewModel: ViewModel = { - let viewModel = ViewModel() - viewModel.bind(cell: self) - return viewModel - }() - - let sensitiveToggleButtonBlurVisualEffectView: UIVisualEffectView = { - let visualEffectView = UIVisualEffectView(effect: ContentWarningOverlayView.blurVisualEffect) - visualEffectView.layer.masksToBounds = true - visualEffectView.layer.cornerRadius = 6 - visualEffectView.layer.cornerCurve = .continuous - return visualEffectView - }() - let sensitiveToggleButtonVibrancyVisualEffectView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: ContentWarningOverlayView.blurVisualEffect)) - let sensitiveToggleButton: HitTestExpandedButton = { - let button = HitTestExpandedButton(type: .system) - button.setImage(Asset.Human.eyeSlashMini.image.withRenderingMode(.alwaysTemplate), for: .normal) - return button - }() - - public let contentWarningOverlayView: ContentWarningOverlayView = { - let overlay = ContentWarningOverlayView() - overlay.layer.masksToBounds = true - overlay.layer.cornerRadius = MediaView.cornerRadius - overlay.layer.cornerCurve = .continuous - return overlay - }() - - let mediaView = MediaView() - - let collectionViewLayout: CoverFlowStackCollectionViewLayout = { - let layout = CoverFlowStackCollectionViewLayout() - layout.sizeScaleRatio = 0.9 - return layout - }() - private(set) lazy var collectionView: UICollectionView = { - let collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout) - collectionView.backgroundColor = .clear - collectionView.layer.masksToBounds = true - collectionView.layer.cornerRadius = MediaView.cornerRadius - collectionView.layer.cornerCurve = .continuous - return collectionView - }() - var diffableDataSource: UICollectionViewDiffableDataSource? override func prepareForReuse() { super.prepareForReuse() - disposeBag.removeAll() - mediaView.prepareForReuse() - diffableDataSource?.applySnapshotUsingReloadData(.init()) + contentConfiguration = nil + delegate = nil } override init(frame: CGRect) { @@ -92,94 +44,7 @@ final class StatusMediaGalleryCollectionCell: UICollectionViewCell { extension StatusMediaGalleryCollectionCell { private func _init() { - mediaView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(mediaView) - NSLayoutConstraint.activate([ - mediaView.topAnchor.constraint(equalTo: contentView.topAnchor), - mediaView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - mediaView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - mediaView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - ]) - - collectionView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(collectionView) - NSLayoutConstraint.activate([ - collectionView.topAnchor.constraint(equalTo: contentView.topAnchor), - collectionView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - collectionView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - collectionView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - ]) - - // sensitiveToggleButton - sensitiveToggleButtonBlurVisualEffectView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(sensitiveToggleButtonBlurVisualEffectView) - NSLayoutConstraint.activate([ - sensitiveToggleButtonBlurVisualEffectView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), - sensitiveToggleButtonBlurVisualEffectView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8), - ]) - - sensitiveToggleButtonVibrancyVisualEffectView.translatesAutoresizingMaskIntoConstraints = false - sensitiveToggleButtonBlurVisualEffectView.contentView.addSubview(sensitiveToggleButtonVibrancyVisualEffectView) - NSLayoutConstraint.activate([ - sensitiveToggleButtonVibrancyVisualEffectView.topAnchor.constraint(equalTo: sensitiveToggleButtonBlurVisualEffectView.contentView.topAnchor), - sensitiveToggleButtonVibrancyVisualEffectView.leadingAnchor.constraint(equalTo: sensitiveToggleButtonBlurVisualEffectView.contentView.leadingAnchor), - sensitiveToggleButtonVibrancyVisualEffectView.trailingAnchor.constraint(equalTo: sensitiveToggleButtonBlurVisualEffectView.contentView.trailingAnchor), - sensitiveToggleButtonVibrancyVisualEffectView.bottomAnchor.constraint(equalTo: sensitiveToggleButtonBlurVisualEffectView.contentView.bottomAnchor), - ]) - - sensitiveToggleButton.translatesAutoresizingMaskIntoConstraints = false - sensitiveToggleButtonVibrancyVisualEffectView.contentView.addSubview(sensitiveToggleButton) - NSLayoutConstraint.activate([ - sensitiveToggleButton.topAnchor.constraint(equalTo: sensitiveToggleButtonVibrancyVisualEffectView.contentView.topAnchor, constant: 4), - sensitiveToggleButton.leadingAnchor.constraint(equalTo: sensitiveToggleButtonVibrancyVisualEffectView.contentView.leadingAnchor, constant: 4), - sensitiveToggleButtonVibrancyVisualEffectView.contentView.trailingAnchor.constraint(equalTo: sensitiveToggleButton.trailingAnchor, constant: 4), - sensitiveToggleButtonVibrancyVisualEffectView.contentView.bottomAnchor.constraint(equalTo: sensitiveToggleButton.bottomAnchor, constant: 4), - ]) - - // contentWarningOverlayView - contentWarningOverlayView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(contentWarningOverlayView) // should add to container - NSLayoutConstraint.activate([ - contentWarningOverlayView.topAnchor.constraint(equalTo: contentView.topAnchor), - contentWarningOverlayView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - contentWarningOverlayView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - contentWarningOverlayView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - ]) - - // delegate interaction to collection view - mediaView.isUserInteractionEnabled = false - - collectionView.delegate = self - let configuration = CoverFlowStackSection.Configuration() - diffableDataSource = CoverFlowStackSection.diffableDataSource( - collectionView: collectionView, - configuration: configuration - ) - - sensitiveToggleButton.addTarget(self, action: #selector(StatusMediaGalleryCollectionCell.sensitiveToggleButtonDidPressed(_:)), for: .touchUpInside) - contentWarningOverlayView.delegate = self + // nothing } } - -extension StatusMediaGalleryCollectionCell { - @objc private func sensitiveToggleButtonDidPressed(_ sender: UIButton) { - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") - delegate?.statusMediaGalleryCollectionCell(self, toggleContentWarningOverlayViewDisplay: contentWarningOverlayView) - } -} - -// MARK: - UICollectionViewDelegate -extension StatusMediaGalleryCollectionCell: UICollectionViewDelegate { - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): did select \(indexPath.debugDescription)") - delegate?.statusMediaGalleryCollectionCell(self, coverFlowCollectionView: collectionView, didSelectItemAt: indexPath) - } -} - -// MARK: - ContentWarningOverlayViewDelegate -extension StatusMediaGalleryCollectionCell: ContentWarningOverlayViewDelegate { - func contentWarningOverlayViewDidPressed(_ contentWarningOverlayView: ContentWarningOverlayView) { - delegate?.statusMediaGalleryCollectionCell(self, toggleContentWarningOverlayViewDisplay: contentWarningOverlayView) - } -} diff --git a/TwidereX/Scene/Share/View/Container/EmptyStateView.swift b/TwidereX/Scene/Share/View/Container/EmptyStateView.swift deleted file mode 100644 index 28b0fc1d..00000000 --- a/TwidereX/Scene/Share/View/Container/EmptyStateView.swift +++ /dev/null @@ -1,133 +0,0 @@ -// -// EmptyStateView.swift -// TwidereX -// -// Created by Cirno MainasuK on 2020-12-29. -// Copyright © 2020 Twidere. All rights reserved. -// - -import UIKit - -final class EmptyStateView: UIView { - - let iconImageView: UIImageView = { - let imageView = UIImageView() - imageView.tintColor = UIColor.secondaryLabel.withAlphaComponent(0.5) - return imageView - }() - - let titleLabel: UILabel = { - let label = UILabel() - label.font = .preferredFont(forTextStyle: .headline) - label.textAlignment = .center - label.textColor = .secondaryLabel - label.text = " " - return label - }() - - let messageLabel: UILabel = { - let label = UILabel() - label.font = .preferredFont(forTextStyle: .footnote) - label.textAlignment = .center - label.textColor = .secondaryLabel - label.text = " " - return label - }() - - override init(frame: CGRect) { - super.init(frame: frame) - _init() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } - -} - -extension EmptyStateView { - private func _init() { - preservesSuperviewLayoutMargins = true - - let topPaddingView = UIView() - let centerPaddingView = UIView() - let bottomPaddingView = UIView() - - topPaddingView.translatesAutoresizingMaskIntoConstraints = false - addSubview(topPaddingView) - NSLayoutConstraint.activate([ - topPaddingView.topAnchor.constraint(equalTo: topAnchor), - topPaddingView.leadingAnchor.constraint(equalTo: leadingAnchor), - topPaddingView.trailingAnchor.constraint(equalTo: trailingAnchor), - ]) - - iconImageView.translatesAutoresizingMaskIntoConstraints = false - addSubview(iconImageView) - NSLayoutConstraint.activate([ - iconImageView.topAnchor.constraint(equalTo: topPaddingView.bottomAnchor), - iconImageView.centerXAnchor.constraint(equalTo: centerXAnchor), - iconImageView.widthAnchor.constraint(equalToConstant: 120).priority(.defaultHigh), - iconImageView.heightAnchor.constraint(equalToConstant: 120).priority(.defaultHigh), - ]) - - centerPaddingView.translatesAutoresizingMaskIntoConstraints = false - addSubview(centerPaddingView) - NSLayoutConstraint.activate([ - centerPaddingView.topAnchor.constraint(equalTo: iconImageView.bottomAnchor), - centerPaddingView.leadingAnchor.constraint(equalTo: leadingAnchor), - centerPaddingView.trailingAnchor.constraint(equalTo: trailingAnchor), - ]) - - titleLabel.translatesAutoresizingMaskIntoConstraints = false - addSubview(titleLabel) - NSLayoutConstraint.activate([ - titleLabel.topAnchor.constraint(equalTo: iconImageView.bottomAnchor), - titleLabel.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor), - titleLabel.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor), - ]) - - messageLabel.translatesAutoresizingMaskIntoConstraints = false - addSubview(messageLabel) - NSLayoutConstraint.activate([ - messageLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor), - messageLabel.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor), - messageLabel.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor), - ]) - - bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false - addSubview(bottomPaddingView) - NSLayoutConstraint.activate([ - bottomPaddingView.topAnchor.constraint(equalTo: messageLabel.bottomAnchor), - bottomPaddingView.leadingAnchor.constraint(equalTo: leadingAnchor), - bottomPaddingView.trailingAnchor.constraint(equalTo: trailingAnchor), - bottomAnchor.constraint(equalTo: bottomPaddingView.bottomAnchor) - ]) - - NSLayoutConstraint.activate([ - centerPaddingView.heightAnchor.constraint(equalTo: topPaddingView.heightAnchor, multiplier: 0.5), - bottomPaddingView.heightAnchor.constraint(equalTo: topPaddingView.heightAnchor, multiplier: 1.0), - ]) - } -} - - -#if canImport(SwiftUI) && DEBUG -import SwiftUI - -struct EmptyStateView_Previews: PreviewProvider { - - static var previews: some View { - UIViewPreview { - let emptyStateView = EmptyStateView() - emptyStateView.iconImageView.image = Asset.Human.eyeSlashLarge.image.withRenderingMode(.alwaysTemplate) - emptyStateView.titleLabel.text = "Permission Denied" - - return emptyStateView - } - } - -} - -#endif - diff --git a/TwidereX/Scene/Share/View/Content/TimelineHeaderView.swift b/TwidereX/Scene/Share/View/Content/TimelineHeaderView.swift index c710a78e..dd3856bf 100644 --- a/TwidereX/Scene/Share/View/Content/TimelineHeaderView.swift +++ b/TwidereX/Scene/Share/View/Content/TimelineHeaderView.swift @@ -8,6 +8,7 @@ import UIKit import MetaTextKit +import MetaLabel final class TimelineHeaderView: UIView { diff --git a/TwidereX/Scene/Share/View/Content/UserBriefInfoView.swift b/TwidereX/Scene/Share/View/Content/UserBriefInfoView.swift index 44861cde..d2e8caa3 100644 --- a/TwidereX/Scene/Share/View/Content/UserBriefInfoView.swift +++ b/TwidereX/Scene/Share/View/Content/UserBriefInfoView.swift @@ -9,6 +9,7 @@ import UIKit import Combine import MetaTextKit +import MetaLabel final class UserBriefInfoView: UIView { diff --git a/TwidereX/Scene/Share/View/TableViewCell/StatusTableViewCell+ViewModel.swift b/TwidereX/Scene/Share/View/TableViewCell/StatusTableViewCell+ViewModel.swift deleted file mode 100644 index 46f6ec2a..00000000 --- a/TwidereX/Scene/Share/View/TableViewCell/StatusTableViewCell+ViewModel.swift +++ /dev/null @@ -1,130 +0,0 @@ -// -// StatusTableViewCell+ViewModel.swift -// StatusTableViewCell+ViewModel -// -// Created by Cirno MainasuK on 2021-8-27. -// Copyright © 2021 Twidere. All rights reserved. -// - -import UIKit -import Combine -import SwiftUI -import CoreDataStack -import AppShared -import TwidereUI - -extension StatusTableViewCell { - - final class ViewModel { - enum Value { - case feed(Feed) - case statusObject(StatusObject) - case twitterStatus(TwitterStatus) - case mastodonStatus(MastodonStatus) - } - - let value: Value - - init(value: Value) { - self.value = value - } - } - - func configure( - tableView: UITableView, - viewModel: ViewModel, - configurationContext: StatusView.ConfigurationContext, - delegate: StatusViewTableViewCellDelegate? - ) { - if statusView.frame == .zero { - // set status view width - statusView.frame.size.width = tableView.readableContentGuide.layoutFrame.width - let contentMaxLayoutWidth = statusView.contentMaxLayoutWidth - statusView.quoteStatusView?.frame.size.width = contentMaxLayoutWidth - // set preferredMaxLayoutWidth for content - statusView.spoilerContentTextView.preferredMaxLayoutWidth = contentMaxLayoutWidth - statusView.contentTextView.preferredMaxLayoutWidth = contentMaxLayoutWidth - statusView.quoteStatusView?.contentTextView.preferredMaxLayoutWidth = statusView.quoteStatusView?.contentMaxLayoutWidth - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): did layout for new cell") - } - - switch viewModel.value { - case .feed(let feed): - statusView.configure( - feed: feed, - configurationContext: configurationContext - ) - configureSeparator(style: feed.hasMore ? .edge : .inset) - case .statusObject(let object): - statusView.configure( - statusObject: object, - configurationContext: configurationContext - ) - configureSeparator(style: .inset) - case .twitterStatus(let status): - statusView.configure( - status: status, - configurationContext: configurationContext - ) - configureSeparator(style: .inset) - case .mastodonStatus(let status): - statusView.configure( - status: status, - notification: nil, - configurationContext: configurationContext - ) - configureSeparator(style: .inset) - } - - self.delegate = delegate - - statusView.viewModel.$isContentReveal - .removeDuplicates() - .dropFirst() - .receive(on: DispatchQueue.main) - .sink { [weak tableView, weak self] _ in - guard let tableView = tableView else { return } - guard let _ = self else { return } - UIView.setAnimationsEnabled(false) - tableView.beginUpdates() - tableView.endUpdates() - UIView.setAnimationsEnabled(true) - } - .store(in: &disposeBag) - } - -} - - -extension StatusTableViewCell { - enum SeparatorStyle { - case edge - case inset - } - - func configureSeparator(style: SeparatorStyle) { - separator.removeFromSuperview() - separator.removeConstraints(separator.constraints) - - switch style { - case .edge: - separator.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(separator) - NSLayoutConstraint.activate([ - separator.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - separator.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - separator.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - separator.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)), - ]) - case .inset: - separator.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(separator) - NSLayoutConstraint.activate([ - separator.leadingAnchor.constraint(equalTo: statusView.toolbar.leadingAnchor), - separator.trailingAnchor.constraint(equalTo: statusView.toolbar.trailingAnchor), - separator.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - separator.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)), - ]) - } - } -} diff --git a/TwidereX/Scene/Share/View/TableViewCell/StatusTableViewCell.swift b/TwidereX/Scene/Share/View/TableViewCell/StatusTableViewCell.swift deleted file mode 100644 index 2a1b6a4d..00000000 --- a/TwidereX/Scene/Share/View/TableViewCell/StatusTableViewCell.swift +++ /dev/null @@ -1,119 +0,0 @@ -// -// StatusTableViewCell.swift -// StatusTableViewCell -// -// Created by Cirno MainasuK on 2021-8-20. -// Copyright © 2021 Twidere. All rights reserved. -// - -import os.log -import UIKit -import Combine -import TwidereUI - -class StatusTableViewCell: UITableViewCell { - - private var _disposeBag = Set() - var disposeBag = Set() - - let logger = Logger(subsystem: "StatusTableViewCell", category: "View") - - weak var delegate: StatusViewTableViewCellDelegate? - - let topConversationLinkLineView = SeparatorLineView() - let statusView = StatusView() - let bottomConversationLinkLineView = SeparatorLineView() - let separator = SeparatorLineView() - - override func prepareForReuse() { - super.prepareForReuse() - - statusView.prepareForReuse() - disposeBag.removeAll() - topConversationLinkLineView.isHidden = true - bottomConversationLinkLineView.isHidden = true - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - _init() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } - -} - -extension StatusTableViewCell { - - private func _init() { - statusView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(statusView) - NSLayoutConstraint.activate([ - statusView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12), - statusView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), - statusView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), - statusView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - ]) - statusView.setup(style: .inline) - statusView.toolbar.setup(style: .inline) - - topConversationLinkLineView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(topConversationLinkLineView) - NSLayoutConstraint.activate([ - topConversationLinkLineView.topAnchor.constraint(equalTo: contentView.topAnchor), - topConversationLinkLineView.centerXAnchor.constraint(equalTo: statusView.authorAvatarButton.centerXAnchor), - topConversationLinkLineView.widthAnchor.constraint(equalToConstant: 1), - statusView.authorAvatarButton.topAnchor.constraint(equalTo: topConversationLinkLineView.bottomAnchor, constant: 2), - ]) - topConversationLinkLineView.isHidden = true - - bottomConversationLinkLineView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(bottomConversationLinkLineView) - NSLayoutConstraint.activate([ - bottomConversationLinkLineView.topAnchor.constraint(equalTo: statusView.authorAvatarButton.bottomAnchor, constant: 2), - bottomConversationLinkLineView.centerXAnchor.constraint(equalTo: statusView.authorAvatarButton.centerXAnchor), - bottomConversationLinkLineView.widthAnchor.constraint(equalToConstant: 1), - contentView.bottomAnchor.constraint(equalTo: bottomConversationLinkLineView.bottomAnchor), - ]) - bottomConversationLinkLineView.isHidden = true - - statusView.delegate = self - - // a11y - isAccessibilityElement = true - statusView.viewModel.$groupedAccessibilityLabel - .receive(on: DispatchQueue.main) - .sink { [weak self] accessibilityLabel in - guard let self = self else { return } - self.accessibilityLabel = accessibilityLabel - } - .store(in: &_disposeBag) - } - - override func accessibilityActivate() -> Bool { - delegate?.tableViewCell(self, statusView: statusView, accessibilityActivate: Void()) - return true - } - -} - -extension StatusTableViewCell { - - func setTopConversationLinkLineViewDisplay() { - topConversationLinkLineView.isHidden = false - } - - func setBottomConversationLinkLineViewDisplay() { - bottomConversationLinkLineView.isHidden = false - } - -} - -// MARK: - StatusViewContainerTableViewCell -extension StatusTableViewCell: StatusViewContainerTableViewCell { } - -// MARK: - StatusViewDelegate -extension StatusTableViewCell: StatusViewDelegate { } diff --git a/TwidereX/Scene/Share/View/TableViewCell/StatusThreadRootTableViewCell+ViewModel.swift b/TwidereX/Scene/Share/View/TableViewCell/StatusThreadRootTableViewCell+ViewModel.swift index 66dee86b..bb5e88d5 100644 --- a/TwidereX/Scene/Share/View/TableViewCell/StatusThreadRootTableViewCell+ViewModel.swift +++ b/TwidereX/Scene/Share/View/TableViewCell/StatusThreadRootTableViewCell+ViewModel.swift @@ -10,9 +10,6 @@ import UIKit import Combine import SwiftUI import CoreDataStack -import TwidereCore -import AppShared -import TwidereUI extension StatusThreadRootTableViewCell { @@ -32,59 +29,59 @@ extension StatusThreadRootTableViewCell { } } - func configure( - tableView: UITableView, - viewModel: StatusThreadRootTableViewCell.ViewModel, - configurationContext: StatusView.ConfigurationContext, - delegate: StatusViewTableViewCellDelegate? - ) { - if statusView.frame == .zero { - // set status view width - statusView.frame.size.width = tableView.readableContentGuide.layoutFrame.width - let contentMaxLayoutWidth = statusView.contentMaxLayoutWidth - statusView.quoteStatusView?.frame.size.width = contentMaxLayoutWidth - // set preferredMaxLayoutWidth for content - statusView.contentTextView.preferredMaxLayoutWidth = contentMaxLayoutWidth - statusView.quoteStatusView?.contentTextView.preferredMaxLayoutWidth = statusView.quoteStatusView?.contentMaxLayoutWidth - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): did layout for new cell") - } - - switch viewModel.value { - case .statusObject(let object): - statusView.configure( - statusObject: object, - configurationContext: configurationContext - ) - case .twitterStatus(let status): - statusView.configure( - status: status, - configurationContext: configurationContext - ) - case .mastodonStatus(let status): - statusView.configure( - status: status, - notification: nil, - configurationContext: configurationContext - ) - } - - self.delegate = delegate - - Publishers.CombineLatest( - statusView.viewModel.$isContentReveal.removeDuplicates(), - statusView.viewModel.$isTranslateButtonDisplay.removeDuplicates() - ) - .dropFirst() - .receive(on: DispatchQueue.main) - .sink { [weak tableView, weak self] _ in - guard let tableView = tableView else { return } - guard let _ = self else { return } - UIView.setAnimationsEnabled(false) - tableView.beginUpdates() - tableView.endUpdates() - UIView.setAnimationsEnabled(true) - } - .store(in: &disposeBag) - } +// func configure( +// tableView: UITableView, +// viewModel: StatusThreadRootTableViewCell.ViewModel, +// configurationContext: StatusView.ConfigurationContext, +// delegate: StatusViewTableViewCellDelegate? +// ) { +// if statusView.frame == .zero { +// // set status view width +// statusView.frame.size.width = tableView.readableContentGuide.layoutFrame.width +// let contentMaxLayoutWidth = statusView.contentMaxLayoutWidth +// statusView.quoteStatusView?.frame.size.width = contentMaxLayoutWidth +// // set preferredMaxLayoutWidth for content +// statusView.contentTextView.preferredMaxLayoutWidth = contentMaxLayoutWidth +// statusView.quoteStatusView?.contentTextView.preferredMaxLayoutWidth = statusView.quoteStatusView?.contentMaxLayoutWidth +// logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): did layout for new cell") +// } +// +// switch viewModel.value { +// case .statusObject(let object): +// statusView.configure( +// statusObject: object, +// configurationContext: configurationContext +// ) +// case .twitterStatus(let status): +// statusView.configure( +// status: status, +// configurationContext: configurationContext +// ) +// case .mastodonStatus(let status): +// statusView.configure( +// status: status, +// notification: nil, +// configurationContext: configurationContext +// ) +// } +// +// self.delegate = delegate +// +// Publishers.CombineLatest( +// statusView.viewModel.$isContentReveal.removeDuplicates(), +// statusView.viewModel.$isTranslateButtonDisplay.removeDuplicates() +// ) +// .dropFirst() +// .receive(on: DispatchQueue.main) +// .sink { [weak tableView, weak self] _ in +// guard let tableView = tableView else { return } +// guard let _ = self else { return } +// UIView.setAnimationsEnabled(false) +// tableView.beginUpdates() +// tableView.endUpdates() +// UIView.setAnimationsEnabled(true) +// } +// .store(in: &disposeBag) +// } } diff --git a/TwidereX/Scene/Share/View/TableViewCell/StatusThreadRootTableViewCell.swift b/TwidereX/Scene/Share/View/TableViewCell/StatusThreadRootTableViewCell.swift index 47c63bfe..73239c05 100644 --- a/TwidereX/Scene/Share/View/TableViewCell/StatusThreadRootTableViewCell.swift +++ b/TwidereX/Scene/Share/View/TableViewCell/StatusThreadRootTableViewCell.swift @@ -9,7 +9,6 @@ import os.log import UIKit import Combine -import TwidereUI final class StatusThreadRootTableViewCell: UITableViewCell { @@ -19,17 +18,17 @@ final class StatusThreadRootTableViewCell: UITableViewCell { weak var delegate: StatusViewTableViewCellDelegate? - let conversationLinkLineView = SeparatorLineView() - let statusView = StatusView() - let toolbarSeparator = SeparatorLineView() - let separator = SeparatorLineView() +// let conversationLinkLineView = SeparatorLineView() +// let statusView = StatusView() +// let toolbarSeparator = SeparatorLineView() +// let separator = SeparatorLineView() override func prepareForReuse() { super.prepareForReuse() - statusView.prepareForReuse() - disposeBag.removeAll() - conversationLinkLineView.isHidden = true +// statusView.prepareForReuse() +// disposeBag.removeAll() +// conversationLinkLineView.isHidden = true } @@ -50,92 +49,92 @@ extension StatusThreadRootTableViewCell { private func _init() { selectionStyle = .none - statusView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(statusView) - NSLayoutConstraint.activate([ - statusView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12), - statusView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), - statusView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), - statusView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - ]) - statusView.setup(style: .plain) - statusView.toolbar.setup(style: .plain) - - conversationLinkLineView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(conversationLinkLineView) - NSLayoutConstraint.activate([ - conversationLinkLineView.topAnchor.constraint(equalTo: contentView.topAnchor), - conversationLinkLineView.centerXAnchor.constraint(equalTo: statusView.authorAvatarButton.centerXAnchor), - conversationLinkLineView.widthAnchor.constraint(equalToConstant: 1), - statusView.authorAvatarButton.topAnchor.constraint(equalTo: conversationLinkLineView.bottomAnchor, constant: 2), - ]) - conversationLinkLineView.isHidden = true - - toolbarSeparator.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(toolbarSeparator) - NSLayoutConstraint.activate([ - toolbarSeparator.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - toolbarSeparator.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - toolbarSeparator.bottomAnchor.constraint(equalTo: statusView.toolbar.topAnchor), - ]) - - separator.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(separator) - NSLayoutConstraint.activate([ - separator.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - separator.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - separator.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - ]) - - statusView.delegate = self - - isAccessibilityElement = false +// statusView.translatesAutoresizingMaskIntoConstraints = false +// contentView.addSubview(statusView) +// NSLayoutConstraint.activate([ +// statusView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12), +// statusView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), +// statusView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), +// statusView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), +// ]) +// statusView.setup(style: .plain) +// statusView.toolbar.setup(style: .plain) +// +// conversationLinkLineView.translatesAutoresizingMaskIntoConstraints = false +// contentView.addSubview(conversationLinkLineView) +// NSLayoutConstraint.activate([ +// conversationLinkLineView.topAnchor.constraint(equalTo: contentView.topAnchor), +// conversationLinkLineView.centerXAnchor.constraint(equalTo: statusView.authorAvatarButton.centerXAnchor), +// conversationLinkLineView.widthAnchor.constraint(equalToConstant: 1), +// statusView.authorAvatarButton.topAnchor.constraint(equalTo: conversationLinkLineView.bottomAnchor, constant: 2), +// ]) +// conversationLinkLineView.isHidden = true +// +// toolbarSeparator.translatesAutoresizingMaskIntoConstraints = false +// contentView.addSubview(toolbarSeparator) +// NSLayoutConstraint.activate([ +// toolbarSeparator.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), +// toolbarSeparator.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), +// toolbarSeparator.bottomAnchor.constraint(equalTo: statusView.toolbar.topAnchor), +// ]) +// +// separator.translatesAutoresizingMaskIntoConstraints = false +// contentView.addSubview(separator) +// NSLayoutConstraint.activate([ +// separator.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), +// separator.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), +// separator.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), +// ]) +// +// statusView.delegate = self +// +// isAccessibilityElement = false } } extension StatusThreadRootTableViewCell { - override var accessibilityElements: [Any]? { - get { - let elements: [UIView?] = [ - statusView.headerTextLabel, - statusView.authorAvatarButton, - statusView.authorNameLabel, - statusView.authorUsernameLabel, - statusView.visibilityImageView, - statusView.spoilerContentTextView, - statusView.expandContentButton, - statusView.contentTextView, - statusView.mediaGridContainerView, - statusView.pollTableView, - statusView.pollVoteDescriptionLabel, - statusView.pollVoteButton, - statusView.quoteStatusView, - statusView.locationLabel, - statusView.metricsDashboardView, - statusView.toolbar, - ] - - return elements - .compactMap { $0 } - .filter { !$0.isHidden } - } - set { } - } +// override var accessibilityElements: [Any]? { +// get { +// let elements: [UIView?] = [ +// statusView.headerTextLabel, +// statusView.authorAvatarButton, +// statusView.authorNameLabel, +// statusView.authorUsernameLabel, +// statusView.visibilityImageView, +// statusView.spoilerContentTextView, +// statusView.expandContentButton, +// statusView.contentTextView, +// statusView.mediaGridContainerView, +// statusView.pollTableView, +// statusView.pollVoteDescriptionLabel, +// statusView.pollVoteButton, +// statusView.quoteStatusView, +// statusView.locationLabel, +// statusView.metricsDashboardView, +// statusView.toolbar, +// ] +// +// return elements +// .compactMap { $0 } +// .filter { !$0.isHidden } +// } +// set { } +// } } extension StatusThreadRootTableViewCell { - func setConversationLinkLineViewDisplay() { - conversationLinkLineView.isHidden = false - } +// func setConversationLinkLineViewDisplay() { +// conversationLinkLineView.isHidden = false +// } } // MARK: - StatusViewContainerTableViewCell -extension StatusThreadRootTableViewCell: StatusViewContainerTableViewCell { } +//extension StatusThreadRootTableViewCell: StatusViewContainerTableViewCell { } // MARK: - StatusViewDelegate -extension StatusThreadRootTableViewCell: StatusViewDelegate { } +//extension StatusThreadRootTableViewCell: StatusViewDelegate { } diff --git a/TwidereX/Scene/Share/View/TableViewCell/StatusViewTableViewCellDelegate.swift b/TwidereX/Scene/Share/View/TableViewCell/StatusViewTableViewCellDelegate.swift deleted file mode 100644 index c68c2721..00000000 --- a/TwidereX/Scene/Share/View/TableViewCell/StatusViewTableViewCellDelegate.swift +++ /dev/null @@ -1,115 +0,0 @@ -// -// StatusViewTableViewCellDelegate.swift -// StatusViewTableViewCellDelegate -// -// Created by Cirno MainasuK on 2021-9-8. -// Copyright © 2021 Twidere. All rights reserved. -// - -import UIKit -import TwidereUI -import MetaTextArea -import Meta - -// sourcery: protocolName = "StatusViewDelegate" -// sourcery: replaceOf = "statusView(statusView" -// sourcery: replaceWith = "delegate?.tableViewCell(self, statusView: statusView" -protocol StatusViewContainerTableViewCell: UITableViewCell, AutoGenerateProtocolRelayDelegate { - var delegate: StatusViewTableViewCellDelegate? { get } - var statusView: StatusView { get } -} - -// MARK: - AutoGenerateProtocolDelegate -// sourcery: protocolName = "StatusViewDelegate" -// sourcery: replaceOf = "statusView(_" -// sourcery: replaceWith = "func tableViewCell(_ cell: UITableViewCell," -protocol StatusViewTableViewCellDelegate: AutoGenerateProtocolDelegate { - // sourcery:inline:StatusViewTableViewCellDelegate.AutoGenerateProtocolDelegate - func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, headerDidPressed header: UIView) - func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, authorAvatarButtonDidPressed button: AvatarButton) - func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, quoteStatusView: StatusView, authorAvatarButtonDidPressed button: AvatarButton) - func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, expandContentButtonDidPressed button: UIButton) - func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, metaTextAreaView: MetaTextAreaView, didSelectMeta meta: Meta) - func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, quoteStatusView: StatusView, metaTextAreaView: MetaTextAreaView, didSelectMeta meta: Meta) - func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, mediaGridContainerView containerView: MediaGridContainerView, didTapMediaView mediaView: MediaView, at index: Int) - func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, mediaGridContainerView containerView: MediaGridContainerView, toggleContentWarningOverlayViewDisplay contentWarningOverlayView: ContentWarningOverlayView) - func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, quoteStatusView: StatusView, mediaGridContainerView containerView: MediaGridContainerView, didTapMediaView mediaView: MediaView, at index: Int) - func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, pollTableView tableView: UITableView, didSelectRowAt indexPath: IndexPath) - func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, pollVoteButtonDidPressed button: UIButton) - func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, quoteStatusViewDidPressed quoteStatusView: StatusView) - func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, statusToolbar: StatusToolbar, actionDidPressed action: StatusToolbar.Action, button: UIButton) - func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, statusToolbar: StatusToolbar, menuActionDidPressed action: StatusToolbar.MenuAction, menuButton button: UIButton) - func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, translateButtonDidPressed button: UIButton) - func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, accessibilityActivate: Void) - // sourcery:end -} - -// MARK: - AutoGenerateProtocolDelegate -// Protocol Extension -extension StatusViewDelegate where Self: StatusViewContainerTableViewCell { - // sourcery:inline:StatusViewContainerTableViewCell.AutoGenerateProtocolRelayDelegate - func statusView(_ statusView: StatusView, headerDidPressed header: UIView) { - delegate?.tableViewCell(self, statusView: statusView, headerDidPressed: header) - } - - func statusView(_ statusView: StatusView, authorAvatarButtonDidPressed button: AvatarButton) { - delegate?.tableViewCell(self, statusView: statusView, authorAvatarButtonDidPressed: button) - } - - func statusView(_ statusView: StatusView, quoteStatusView: StatusView, authorAvatarButtonDidPressed button: AvatarButton) { - delegate?.tableViewCell(self, statusView: statusView, quoteStatusView: quoteStatusView, authorAvatarButtonDidPressed: button) - } - - func statusView(_ statusView: StatusView, expandContentButtonDidPressed button: UIButton) { - delegate?.tableViewCell(self, statusView: statusView, expandContentButtonDidPressed: button) - } - - func statusView(_ statusView: StatusView, metaTextAreaView: MetaTextAreaView, didSelectMeta meta: Meta) { - delegate?.tableViewCell(self, statusView: statusView, metaTextAreaView: metaTextAreaView, didSelectMeta: meta) - } - - func statusView(_ statusView: StatusView, quoteStatusView: StatusView, metaTextAreaView: MetaTextAreaView, didSelectMeta meta: Meta) { - delegate?.tableViewCell(self, statusView: statusView, quoteStatusView: quoteStatusView, metaTextAreaView: metaTextAreaView, didSelectMeta: meta) - } - - func statusView(_ statusView: StatusView, mediaGridContainerView containerView: MediaGridContainerView, didTapMediaView mediaView: MediaView, at index: Int) { - delegate?.tableViewCell(self, statusView: statusView, mediaGridContainerView: containerView, didTapMediaView: mediaView, at: index) - } - - func statusView(_ statusView: StatusView, mediaGridContainerView containerView: MediaGridContainerView, toggleContentWarningOverlayViewDisplay contentWarningOverlayView: ContentWarningOverlayView) { - delegate?.tableViewCell(self, statusView: statusView, mediaGridContainerView: containerView, toggleContentWarningOverlayViewDisplay: contentWarningOverlayView) - } - - func statusView(_ statusView: StatusView, quoteStatusView: StatusView, mediaGridContainerView containerView: MediaGridContainerView, didTapMediaView mediaView: MediaView, at index: Int) { - delegate?.tableViewCell(self, statusView: statusView, quoteStatusView: quoteStatusView, mediaGridContainerView: containerView, didTapMediaView: mediaView, at: index) - } - - func statusView(_ statusView: StatusView, pollTableView tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - delegate?.tableViewCell(self, statusView: statusView, pollTableView: tableView, didSelectRowAt: indexPath) - } - - func statusView(_ statusView: StatusView, pollVoteButtonDidPressed button: UIButton) { - delegate?.tableViewCell(self, statusView: statusView, pollVoteButtonDidPressed: button) - } - - func statusView(_ statusView: StatusView, quoteStatusViewDidPressed quoteStatusView: StatusView) { - delegate?.tableViewCell(self, statusView: statusView, quoteStatusViewDidPressed: quoteStatusView) - } - - func statusView(_ statusView: StatusView, statusToolbar: StatusToolbar, actionDidPressed action: StatusToolbar.Action, button: UIButton) { - delegate?.tableViewCell(self, statusView: statusView, statusToolbar: statusToolbar, actionDidPressed: action, button: button) - } - - func statusView(_ statusView: StatusView, statusToolbar: StatusToolbar, menuActionDidPressed action: StatusToolbar.MenuAction, menuButton button: UIButton) { - delegate?.tableViewCell(self, statusView: statusView, statusToolbar: statusToolbar, menuActionDidPressed: action, menuButton: button) - } - - func statusView(_ statusView: StatusView, translateButtonDidPressed button: UIButton) { - delegate?.tableViewCell(self, statusView: statusView, translateButtonDidPressed: button) - } - - func statusView(_ statusView: StatusView, accessibilityActivate: Void) { - delegate?.tableViewCell(self, statusView: statusView, accessibilityActivate: accessibilityActivate) - } - // sourcery:end -} diff --git a/TwidereX/Scene/StatusThread/Mastodon/MastodonStatusThreadViewModel.swift b/TwidereX/Scene/StatusThread/Mastodon/MastodonStatusThreadViewModel.swift index 1fac2b6d..71277a88 100644 --- a/TwidereX/Scene/StatusThread/Mastodon/MastodonStatusThreadViewModel.swift +++ b/TwidereX/Scene/StatusThread/Mastodon/MastodonStatusThreadViewModel.swift @@ -16,170 +16,170 @@ import MastodonMeta final class MastodonStatusThreadViewModel { - var disposeBag = Set() - - // input - let context: AppContext - @Published private(set) var deletedObjectIDs: Set = Set() - - // output - @Published var _ancestors: [StatusItem] = [] - let ancestors = CurrentValueSubject<[StatusItem], Never>([]) - - @Published var _descendants: [StatusItem] = [] - let descendants = CurrentValueSubject<[StatusItem], Never>([]) - - init(context: AppContext) { - self.context = context - - Publishers.CombineLatest( - $_ancestors, - $deletedObjectIDs - ) - .sink { [weak self] items, deletedObjectIDs in - guard let self = self else { return } - let newItems = items.filter { item in - switch item { - case .thread(let thread): - return !deletedObjectIDs.contains(thread.statusRecord.objectID) - default: - assertionFailure() - return false - } - } - self.ancestors.value = newItems - } - .store(in: &disposeBag) - - Publishers.CombineLatest( - $_descendants, - $deletedObjectIDs - ) - .sink { [weak self] items, deletedObjectIDs in - guard let self = self else { return } - let newItems = items.filter { item in - switch item { - case .thread(let thread): - return !deletedObjectIDs.contains(thread.statusRecord.objectID) - default: - assertionFailure() - return false - } - } - self.descendants.value = newItems - } - .store(in: &disposeBag) - } - - deinit { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - } +// var disposeBag = Set() +// +// // input +// let context: AppContext +// @Published private(set) var deletedObjectIDs: Set = Set() +// +// // output +// @Published var _ancestors: [StatusItem] = [] +// let ancestors = CurrentValueSubject<[StatusItem], Never>([]) +// +// @Published var _descendants: [StatusItem] = [] +// let descendants = CurrentValueSubject<[StatusItem], Never>([]) +// +// init(context: AppContext) { +// self.context = context +// +// Publishers.CombineLatest( +// $_ancestors, +// $deletedObjectIDs +// ) +// .sink { [weak self] items, deletedObjectIDs in +// guard let self = self else { return } +// let newItems = items.filter { item in +// switch item { +// case .thread(let thread): +// return !deletedObjectIDs.contains(thread.statusRecord.objectID) +// default: +// assertionFailure() +// return false +// } +// } +// self.ancestors.value = newItems +// } +// .store(in: &disposeBag) +// +// Publishers.CombineLatest( +// $_descendants, +// $deletedObjectIDs +// ) +// .sink { [weak self] items, deletedObjectIDs in +// guard let self = self else { return } +// let newItems = items.filter { item in +// switch item { +// case .thread(let thread): +// return !deletedObjectIDs.contains(thread.statusRecord.objectID) +// default: +// assertionFailure() +// return false +// } +// } +// self.descendants.value = newItems +// } +// .store(in: &disposeBag) +// } +// +// deinit { +// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) +// } } -extension MastodonStatusThreadViewModel { - - func appendAncestor( - domain: String, - nodes: [Node] - ) { - let ids = nodes.map { $0.statusID } - var dictionary: [MastodonStatus.ID: MastodonStatus] = [:] - do { - let request = MastodonStatus.sortedFetchRequest - request.predicate = MastodonStatus.predicate(domain: domain, ids: ids) - let statuses = try self.context.managedObjectContext.fetch(request) - for status in statuses { - dictionary[status.id] = status - } - } catch { - os_log("%{public}s[%{public}ld], %{public}s: fetch conversation fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - return - } - - var newItems: [StatusItem] = [] - for (i, node) in nodes.enumerated() { - guard let status = dictionary[node.statusID] else { continue } - let isLast = i == nodes.count - 1 - - let record = ManagedObjectRecord(objectID: status.objectID) - let context = StatusItem.Thread.Context( - status: .mastodon(record: record), - displayUpperConversationLink: !isLast, - displayBottomConversationLink: true - ) - let item = StatusItem.thread(.leaf(context: context)) - newItems.append(item) - } - - let items = self._ancestors + newItems - self._ancestors = items - } - - func appendDescendant( - domain: String, - nodes: [Node] - ) { - let childrenIDs = nodes - .map { node in [node.statusID, node.children.first?.statusID].compactMap { $0 } } - .flatMap { $0 } - var dictionary: [MastodonStatus.ID: MastodonStatus] = [:] - do { - let request = MastodonStatus.sortedFetchRequest - request.predicate = MastodonStatus.predicate(domain: domain, ids: childrenIDs) - let statuses = try self.context.managedObjectContext.fetch(request) - for status in statuses { - dictionary[status.id] = status - } - } catch { - os_log("%{public}s[%{public}ld], %{public}s: fetch conversation fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - return - } - - var newItems: [StatusItem] = [] - for node in nodes { - guard let status = dictionary[node.statusID] else { continue } - // first tier - let record = ManagedObjectRecord(objectID: status.objectID) - let context = StatusItem.Thread.Context( - status: .mastodon(record: record) - ) - let item = StatusItem.thread(.leaf(context: context)) - newItems.append(item) - - // second tier - if let child = node.children.first { - guard let secondaryStatus = dictionary[child.statusID] else { continue } - let secondaryRecord = ManagedObjectRecord(objectID: secondaryStatus.objectID) - let secondaryContext = StatusItem.Thread.Context( - status: .mastodon(record: secondaryRecord), - displayUpperConversationLink: true - ) - let secondaryItem = StatusItem.thread(.leaf(context: secondaryContext)) - newItems.append(secondaryItem) - - // update first tier context - context.displayBottomConversationLink = true - } - } - - var items = self._descendants - for item in newItems { - guard !items.contains(item) else { continue } - items.append(item) - } - self._descendants = items - } +//extension MastodonStatusThreadViewModel { +// +// func appendAncestor( +// domain: String, +// nodes: [Node] +// ) { +// let ids = nodes.map { $0.statusID } +// var dictionary: [MastodonStatus.ID: MastodonStatus] = [:] +// do { +// let request = MastodonStatus.sortedFetchRequest +// request.predicate = MastodonStatus.predicate(domain: domain, ids: ids) +// let statuses = try self.context.managedObjectContext.fetch(request) +// for status in statuses { +// dictionary[status.id] = status +// } +// } catch { +// os_log("%{public}s[%{public}ld], %{public}s: fetch conversation fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) +// return +// } +// +// var newItems: [StatusItem] = [] +// for (i, node) in nodes.enumerated() { +// guard let status = dictionary[node.statusID] else { continue } +// let isLast = i == nodes.count - 1 +// +// let record = ManagedObjectRecord(objectID: status.objectID) +// let context = StatusItem.Thread.Context( +// status: .mastodon(record: record), +// displayUpperConversationLink: !isLast, +// displayBottomConversationLink: true +// ) +// let item = StatusItem.thread(.leaf(context: context)) +// newItems.append(item) +// } +// +// let items = self._ancestors + newItems +// self._ancestors = items +// } -} +// func appendDescendant( +// domain: String, +// nodes: [Node] +// ) { +// let childrenIDs = nodes +// .map { node in [node.statusID, node.children.first?.statusID].compactMap { $0 } } +// .flatMap { $0 } +// var dictionary: [MastodonStatus.ID: MastodonStatus] = [:] +// do { +// let request = MastodonStatus.sortedFetchRequest +// request.predicate = MastodonStatus.predicate(domain: domain, ids: childrenIDs) +// let statuses = try self.context.managedObjectContext.fetch(request) +// for status in statuses { +// dictionary[status.id] = status +// } +// } catch { +// os_log("%{public}s[%{public}ld], %{public}s: fetch conversation fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) +// return +// } +// +// var newItems: [StatusItem] = [] +// for node in nodes { +// guard let status = dictionary[node.statusID] else { continue } +// // first tier +// let record = ManagedObjectRecord(objectID: status.objectID) +// let context = StatusItem.Thread.Context( +// status: .mastodon(record: record) +// ) +// let item = StatusItem.thread(.leaf(context: context)) +// newItems.append(item) +// +// // second tier +// if let child = node.children.first { +// guard let secondaryStatus = dictionary[child.statusID] else { continue } +// let secondaryRecord = ManagedObjectRecord(objectID: secondaryStatus.objectID) +// let secondaryContext = StatusItem.Thread.Context( +// status: .mastodon(record: secondaryRecord), +// displayUpperConversationLink: true +// ) +// let secondaryItem = StatusItem.thread(.leaf(context: secondaryContext)) +// newItems.append(secondaryItem) +// +// // update first tier context +// context.displayBottomConversationLink = true +// } +// } +// +// var items = self._descendants +// for item in newItems { +// guard !items.contains(item) else { continue } +// items.append(item) +// } +// self._descendants = items +// } +// +//} extension MastodonStatusThreadViewModel { class Node { typealias ID = String - + let statusID: ID let children: [Node] - + init( statusID: ID, children: [MastodonStatusThreadViewModel.Node] @@ -198,12 +198,12 @@ extension MastodonStatusThreadViewModel.Node { guard let replyToID = replyToID else { return [] } - + var dict: [Mastodon.Entity.Status.ID: Mastodon.Entity.Status] = [:] for status in statuses { dict[status.id] = status } - + var nextID: Mastodon.Entity.Status.ID? = replyToID var nodes: [MastodonStatusThreadViewModel.Node] = [] while let _nextID = nextID { @@ -214,7 +214,7 @@ extension MastodonStatusThreadViewModel.Node { )) nextID = status.inReplyToID } - + return nodes } } @@ -226,7 +226,7 @@ extension MastodonStatusThreadViewModel.Node { ) -> [MastodonStatusThreadViewModel.Node] { var dictionary: [ID: Mastodon.Entity.Status] = [:] var mapping: [ID: Set] = [:] - + for status in statuses { dictionary[status.id] = status guard let replyToID = status.inReplyToID else { continue } @@ -237,7 +237,7 @@ extension MastodonStatusThreadViewModel.Node { mapping[replyToID] = Set([status.id]) } } - + var children: [MastodonStatusThreadViewModel.Node] = [] let replies = Array(mapping[statusID] ?? Set()) .compactMap { dictionary[$0] } @@ -248,7 +248,7 @@ extension MastodonStatusThreadViewModel.Node { } return children } - + static func child( of statusID: ID, dictionary: [ID: Mastodon.Entity.Status], @@ -264,15 +264,14 @@ extension MastodonStatusThreadViewModel.Node { children: children ) } - } extension MastodonStatusThreadViewModel { - func delete(objectIDs: [NSManagedObjectID]) { - var set = deletedObjectIDs - for objectID in objectIDs { - set.insert(objectID) - } - self.deletedObjectIDs = set - } +// func delete(objectIDs: [NSManagedObjectID]) { +// var set = deletedObjectIDs +// for objectID in objectIDs { +// set.insert(objectID) +// } +// self.deletedObjectIDs = set +// } } diff --git a/TwidereX/Scene/StatusThread/StatusThreadViewController+DataSourceProvider.swift b/TwidereX/Scene/StatusThread/StatusThreadViewController+DataSourceProvider.swift index c75a2396..9ebe9542 100644 --- a/TwidereX/Scene/StatusThread/StatusThreadViewController+DataSourceProvider.swift +++ b/TwidereX/Scene/StatusThread/StatusThreadViewController+DataSourceProvider.swift @@ -20,12 +20,14 @@ extension StatusThreadViewController: DataSourceProvider { return nil } - guard case let .thread(thread) = item else { return nil } - switch thread { - case .reply(let threadContext), - .root(let threadContext), - .leaf(let threadContext): - return .status(threadContext.status) + switch item { + case .root: + guard let status = viewModel.statusViewModel?.status?.asRecord else { return nil } + return .status(status) + case .status(let status): + return .status(status) + case .topLoader, .bottomLoader: + return nil } } diff --git a/TwidereX/Scene/StatusThread/StatusThreadViewController.swift b/TwidereX/Scene/StatusThread/StatusThreadViewController.swift index e6404163..f09dfa88 100644 --- a/TwidereX/Scene/StatusThread/StatusThreadViewController.swift +++ b/TwidereX/Scene/StatusThread/StatusThreadViewController.swift @@ -44,7 +44,9 @@ extension StatusThreadViewController { override func viewDidLoad() { super.viewDidLoad() + title = "Detail" view.backgroundColor = .systemBackground + viewModel.viewLayoutFrame.update(view: view) tableView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(tableView) @@ -60,22 +62,22 @@ extension StatusThreadViewController { tableView: tableView, statusViewTableViewCellDelegate: self ) - viewModel.topListBatchFetchViewModel.setup(scrollView: tableView) - viewModel.bottomListBatchFetchViewModel.setup(scrollView: tableView) - viewModel.topListBatchFetchViewModel.shouldFetch - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - guard let self = self else { return } - self.viewModel.twitterStatusThreadReplyViewModel.stateMachine.enter(TwitterStatusThreadReplyViewModel.State.Loading.self) - } - .store(in: &disposeBag) - viewModel.bottomListBatchFetchViewModel.shouldFetch - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - guard let self = self else { return } - self.viewModel.loadThreadStateMachine.enter(StatusThreadViewModel.LoadThreadState.Loading.self) - } - .store(in: &disposeBag) +// viewModel.topListBatchFetchViewModel.setup(scrollView: tableView) +// viewModel.bottomListBatchFetchViewModel.setup(scrollView: tableView) +// viewModel.topListBatchFetchViewModel.shouldFetch +// .receive(on: DispatchQueue.main) +// .sink { [weak self] _ in +// guard let self = self else { return } +// self.viewModel.twitterStatusThreadReplyViewModel.stateMachine.enter(TwitterStatusThreadReplyViewModel.State.Loading.self) +// } +// .store(in: &disposeBag) +// viewModel.bottomListBatchFetchViewModel.shouldFetch +// .receive(on: DispatchQueue.main) +// .sink { [weak self] _ in +// guard let self = self else { return } +// self.viewModel.loadThreadStateMachine.enter(StatusThreadViewModel.LoadThreadState.Loading.self) +// } +// .store(in: &disposeBag) } override func viewWillAppear(_ animated: Bool) { @@ -87,7 +89,26 @@ extension StatusThreadViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - viewModel.viewDidAppear.send() +// viewModel.viewDidAppear.send() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + viewModel.viewLayoutFrame.update(view: view) + } + + override func viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + + viewModel.viewLayoutFrame.update(view: view) + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + coordinator.animate { _ in + self.viewModel.viewLayoutFrame.update(view: self.view) + } } } @@ -97,13 +118,31 @@ extension StatusThreadViewController: UITableViewDelegate, AutoGenerateTableView func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { guard let diffableDataSource = viewModel.diffableDataSource else { return indexPath } guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return indexPath } - guard case let .thread(thread) = item else { return indexPath } - switch thread { + switch item { case .root: + // cancel textView selection + view.endEditing(true) return nil - case .reply, .leaf: - return indexPath + default: return indexPath + } + } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + + switch item { + case .topLoader: + Task { + try await viewModel.loadTop() + } // end Task + case .bottomLoader: + Task { + try await viewModel.loadBottom() + } // end Task + default: + break } } @@ -135,6 +174,10 @@ extension StatusThreadViewController: UITableViewDelegate, AutoGenerateTableView } +// MARK: - AuthContextProvider +extension StatusThreadViewController: AuthContextProvider { + var authContext: AuthContext { viewModel.authContext } +} // MARK: - StatusViewTableViewCellDelegate extension StatusThreadViewController: StatusViewTableViewCellDelegate { } diff --git a/TwidereX/Scene/StatusThread/StatusThreadViewModel+Diffable.swift b/TwidereX/Scene/StatusThread/StatusThreadViewModel+Diffable.swift index abafba71..b79a65ba 100644 --- a/TwidereX/Scene/StatusThread/StatusThreadViewModel+Diffable.swift +++ b/TwidereX/Scene/StatusThread/StatusThreadViewModel+Diffable.swift @@ -8,10 +8,10 @@ import os.log import UIKit +import SwiftUI import Combine import CoreData import CoreDataStack -import AppShared extension StatusThreadViewModel { @@ -19,113 +19,167 @@ extension StatusThreadViewModel { tableView: UITableView, statusViewTableViewCellDelegate: StatusViewTableViewCellDelegate ) { - let configuration = StatusSection.Configuration( - statusViewTableViewCellDelegate: statusViewTableViewCellDelegate, - timelineMiddleLoaderTableViewCellDelegate: nil, - statusViewConfigurationContext: .init( - dateTimeProvider: DateTimeSwiftProvider(), - twitterTextProvider: OfficialTwitterTextProvider(), - authenticationContext: context.authenticationService.$activeAuthenticationContext - ) - ) - - diffableDataSource = StatusSection.diffableDataSource( - tableView: tableView, - context: context, - configuration: configuration - ) + tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self)) + tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) + + diffableDataSource = UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, item in + guard let self = self else { return UITableViewCell() } + + switch item { + case .status(let record): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell + cell.statusViewTableViewCellDelegate = statusViewTableViewCellDelegate + self.context.managedObjectContext.performAndWait { + guard let status = record.object(in: self.context.managedObjectContext) else { return } + let viewModel = StatusView.ViewModel( + status: status, + authContext: self.authContext, + kind: .conversationThread, + delegate: cell, + viewLayoutFramePublisher: self.$viewLayoutFrame + ) + if let linkConfiguration = self.conversationLinkConfiguration[record] { + viewModel.isTopConversationLinkLineViewDisplay = linkConfiguration.isTopLinkDisplay + viewModel.isBottomConversationLinkLineViewDisplay = linkConfiguration.isBottomLinkDisplay + } + cell.contentConfiguration = UIHostingConfiguration { + StatusView(viewModel: viewModel) + } + .margins(.vertical, 0) // remove vertical margins + } + return cell + case .root: + let cell = self.conversationRootTableViewCell + guard let viewModel = self.statusViewModel else { + return UITableViewCell() + } + cell.statusViewTableViewCellDelegate = statusViewTableViewCellDelegate + self.updateConversationRootLink(viewModel: viewModel) + cell.contentConfiguration = UIHostingConfiguration { + StatusView(viewModel: viewModel) + } + .margins(.vertical, 0) // remove vertical margins + return cell + case .topLoader, .bottomLoader: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell + cell.activityIndicatorView.startAnimating() + return cell + } + } // end diffableDataSource = UITableViewDiffableDataSource - var snapshot = NSDiffableDataSourceSnapshot() + // initial snapshot + var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) - if hasReplyTo { - snapshot.appendItems([.topLoader], toSection: .main) - } - if let root = self.root.value, case let .root(threadContext) = root { - switch threadContext.status { - case .twitter(let record): - if twitterStatusThreadReplyViewModel.root == nil { - twitterStatusThreadReplyViewModel.root = record + switch kind { + case .status(let status): + // top loader + let hasReplyTo: Bool = { + guard let status = status.object(in: context.managedObjectContext) else { return false } + switch status { + case .twitter(let status): return (status.repost ?? status).replyToStatusID != nil + case .mastodon(let status): return status.replyToStatusID != nil } - case .mastodon: - break + }() + if hasReplyTo { + snapshot.appendItems([.topLoader], toSection: .main) } - - let item = StatusItem.thread(root) - snapshot.appendItems([item, .bottomLoader], toSection: .main) - } else { - root.eraseToAnyPublisher() - .sink { [weak self] root in - guard let self = self else { return } - - guard case .root(let threadContext) = root else { return } - guard case let .twitter(record) = threadContext.status else { return } - - guard self.twitterStatusThreadReplyViewModel.root == nil else { return } - self.twitterStatusThreadReplyViewModel.root = record - } - .store(in: &disposeBag) + // root + snapshot.appendItems([.root]) + // bottom loader + snapshot.appendItems([.bottomLoader]) + case .twitter, .mastodon: + break } - diffableDataSource?.apply(snapshot) - - // trigger thread loading - loadThreadStateMachine.enter(LoadThreadState.Prepare.self) + diffableDataSource?.apply(snapshot, animatingDifferences: false, completion: nil) - Publishers.CombineLatest3( - root, - $replies, - $leafs + Publishers.CombineLatest4( + $status, + $topThreads.removeDuplicates(), + $bottomThreads.removeDuplicates(), + $deleteStatusIDs.removeDuplicates() ) - .throttle(for: 0.3, scheduler: DispatchQueue.main, latest: true) - .sink { [weak self] root, replies, leafs in + .debounce(for: 0.3, scheduler: DispatchQueue.main) + .dropFirst() + .sink { [weak self] status, topThreads, bottomThreads, deleteStatusIDs in guard let self = self else { return } guard let diffableDataSource = self.diffableDataSource else { return } Task { @MainActor in let oldSnapshot = diffableDataSource.snapshot() - var newSnapshot = NSDiffableDataSourceSnapshot() + var newSnapshot = NSDiffableDataSourceSnapshot() newSnapshot.appendSections([.main]) // top loader - if self.hasReplyTo, case let .root(threadContext) = root { - switch threadContext.status { - case .twitter: - let state = self.twitterStatusThreadReplyViewModel.stateMachine.currentState - if state is TwitterStatusThreadReplyViewModel.State.NoMore { - // do nothing - } else { - newSnapshot.appendItems([.topLoader], toSection: .main) - } - case .mastodon: - let state = self.loadThreadStateMachine.currentState - if state is LoadThreadState.NoMore { - // do nothing - } else { - newSnapshot.appendItems([.topLoader], toSection: .main) + switch self.topCursor { + case .none: + // top loader + let hasReplyTo: Bool = { + switch status { + case .twitter(let status): return (status.repost ?? status).replyToStatusID != nil + case .mastodon(let status): return status.replyToStatusID != nil + case nil: return false } + }() + if hasReplyTo { + newSnapshot.appendItems([.topLoader], toSection: .main) } + case .value: + newSnapshot.appendItems([.topLoader], toSection: .main) + default: + break } - // replies - newSnapshot.appendItems(replies.reversed(), toSection: .main) + // self reply + let topItems: [Item] = topThreads.enumerated().compactMap { index, thread -> Item? in + switch thread { + case .selfThread(let status): + let isFirst = index == 0 + let linkConfiguration = LinkConfiguration( + isTopLinkDisplay: isFirst ? self.topCursor.value != nil : true, + isBottomLinkDisplay: true + ) + self.conversationLinkConfiguration[status] = linkConfiguration + return .status(status: status) + default: + return nil + } + }.removingDuplicates() + newSnapshot.appendItems(topItems, toSection: .main) // root - if let root = root { - let item = StatusItem.thread(root) - newSnapshot.appendItems([item], toSection: .main) + newSnapshot.appendItems([.root], toSection: .main) + if let status = status, deleteStatusIDs.contains(status.id) { + newSnapshot.deleteItems([.root]) } - // leafs - newSnapshot.appendItems(leafs, toSection: .main) - // bottom loader - if let currentState = self.loadThreadStateMachine.currentState { - switch currentState { - case is LoadThreadState.Prepare, - is LoadThreadState.Idle, - is LoadThreadState.Loading: - newSnapshot.appendItems([.bottomLoader], toSection: .main) + // bottom reply + let bottomItems: [Item] = bottomThreads.compactMap { thread -> [Item]? in + switch thread { + case .conversationThread(let components): + return components.enumerated().compactMap { index, status -> Item? in + let isFirst = index == 0 + let isLast = index == components.count - 1 + let linkConfiguration = LinkConfiguration( + isTopLinkDisplay: !isFirst, + isBottomLinkDisplay: !isLast + ) + self.conversationLinkConfiguration[status] = linkConfiguration + return Item.status(status: status) + } default: - break + assertionFailure() + return nil } } - + .flatMap { $0 } + .removingDuplicates() + newSnapshot.appendItems(bottomItems, toSection: .main) + // bottom loader + switch self.bottomCursor { + case .none, .value: + newSnapshot.appendItems([.bottomLoader], toSection: .main) + default: + break + } + let hasChanges = newSnapshot.itemIdentifiers != oldSnapshot.itemIdentifiers if !hasChanges { self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): snapshot not changes") @@ -139,115 +193,69 @@ extension StatusThreadViewModel { oldSnapshot: oldSnapshot, newSnapshot: newSnapshot ) else { - await self.updateDataSource(snapshot: newSnapshot, animatingDifferences: false) + self.updateDataSource(snapshot: newSnapshot, animatingDifferences: false) self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): applied new snapshot without tweak") return } - + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Snapshot] oldSnapshot: \(oldSnapshot.itemIdentifiers.debugDescription)") self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Snapshot] newSnapshot: \(newSnapshot.itemIdentifiers.debugDescription)") - await self.updateSnapshotUsingReloadData( + self.reloadSnapshotWithDifference( tableView: tableView, oldSnapshot: oldSnapshot, newSnapshot: newSnapshot, difference: difference ) - } + + } // end Task } .store(in: &disposeBag) } - @MainActor private func updateDataSource( - snapshot: NSDiffableDataSourceSnapshot, - animatingDifferences: Bool - ) async { - await self.diffableDataSource?.apply(snapshot, animatingDifferences: animatingDifferences) - } - - // Some UI tweaks to present replies and conversation smoothly - @MainActor private func updateSnapshotUsingReloadData( - tableView: UITableView, - oldSnapshot: NSDiffableDataSourceSnapshot, - newSnapshot: NSDiffableDataSourceSnapshot, - difference: StatusThreadViewModel.Difference // - ) async { - let replies: [StatusItem] = { - newSnapshot.itemIdentifiers.filter { item in - guard case let .thread(thread) = item else { return false } - guard case .reply = thread else { return false } - return true - } - }() - // additional margin for .topLoader - let oldTopMargin: CGFloat = { - let marginHeight = TimelineTopLoaderTableViewCell.cellHeight - if oldSnapshot.itemIdentifiers.contains(.topLoader) || !replies.isEmpty { - return marginHeight - } - return .zero - }() + private func updateConversationRootLink(viewModel: StatusView.ViewModel) { + guard let record = viewModel.status?.asRecord else { return } + guard let linkConfiguration = conversationLinkConfiguration[record] else { return } - await self.diffableDataSource?.applySnapshotUsingReloadData(newSnapshot) - - // note: - // tweak the content offset and bottom inset - // make the table view stable when data reload - // the keypoint is set the bottom inset to make the root padding with "TopLoaderHeight" to top edge - // and restore the "TopLoaderHeight" when bottom inset adjusted - - // set bottom inset. Make root item pin to top. - if let item = root.value.flatMap({ StatusItem.thread($0) }), - let index = newSnapshot.indexOfItem(item), - let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0)) - { - // always set bottom inset due to lazy reply loading - // otherwise tableView will jump when insert replies - let bottomSpacing = tableView.safeAreaLayoutGuide.layoutFrame.height - cell.frame.height - oldTopMargin - let additionalInset = round(tableView.contentSize.height - cell.frame.maxY) - - tableView.contentInset.bottom = max(0, bottomSpacing - additionalInset) - self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): content inset bottom: \(tableView.contentInset.bottom)") - } - - // set scroll position - tableView.scrollToRow(at: difference.targetIndexPath, at: .top, animated: false) - tableView.contentOffset.y = { - var offset: CGFloat = tableView.contentOffset.y - difference.sourceDistanceToTableViewTopEdge - if tableView.contentInset.bottom != 0.0 { - // needs restore top margin if bottom inset adjusted - offset += oldTopMargin - } - return offset - }() - self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): applied new snapshot") + viewModel.isTopConversationLinkLineViewDisplay = linkConfiguration.isTopLinkDisplay + viewModel.isBottomConversationLinkLineViewDisplay = linkConfiguration.isBottomLinkDisplay + viewModel.repostViewModel?.isTopConversationLinkLineViewDisplay = linkConfiguration.isTopLinkDisplay + viewModel.repostViewModel?.isBottomConversationLinkLineViewDisplay = linkConfiguration.isBottomLinkDisplay } + } extension StatusThreadViewModel { - struct Difference { - let item: StatusItem + struct Difference: CustomStringConvertible { + let item: T let sourceIndexPath: IndexPath let sourceDistanceToTableViewTopEdge: CGFloat let targetIndexPath: IndexPath + + var description: String { + """ + source: \(sourceIndexPath.debugDescription) + target: \(targetIndexPath.debugDescription) + offset: \(sourceDistanceToTableViewTopEdge) + item: \(String(describing: item)) + """ + } } - - @MainActor private func calculateReloadSnapshotDifference( + + @MainActor func calculateReloadSnapshotDifference( tableView: UITableView, - oldSnapshot: NSDiffableDataSourceSnapshot, - newSnapshot: NSDiffableDataSourceSnapshot - ) -> Difference? { + oldSnapshot: NSDiffableDataSourceSnapshot, + newSnapshot: NSDiffableDataSourceSnapshot + ) -> Difference? { guard oldSnapshot.numberOfItems != 0 else { return nil } guard let indexPathsForVisibleRows = tableView.indexPathsForVisibleRows?.sorted() else { return nil } // find index of the first visible item in both old and new snapshot var _index: Int? - let items = oldSnapshot.itemIdentifiers(inSection: .main) + let items = oldSnapshot.itemIdentifiers for (i, item) in items.enumerated() { - guard let indexPath = indexPathsForVisibleRows.first(where: { $0.row == i }) else { continue } + guard let _ = indexPathsForVisibleRows.first(where: { $0.row == i }) else { continue } + guard !item.isTransient else { continue } guard newSnapshot.indexOfItem(item) != nil else { continue } - let rectForCell = tableView.rectForRow(at: indexPath) - let distanceToTableViewTopEdge = tableView.convert(rectForCell, to: nil).origin.y - tableView.safeAreaInsets.top - guard distanceToTableViewTopEdge >= 0 else { continue } _index = i break } @@ -256,22 +264,24 @@ extension StatusThreadViewModel { let sourceIndexPath = IndexPath(row: index, section: 0) let rectForSourceItemCell = tableView.rectForRow(at: sourceIndexPath) - let sourceDistanceToTableViewTopEdge = tableView.convert(rectForSourceItemCell, to: nil).origin.y - tableView.safeAreaInsets.top - - guard sourceIndexPath.section < oldSnapshot.numberOfSections, - sourceIndexPath.row < oldSnapshot.numberOfItems(inSection: oldSnapshot.sectionIdentifiers[sourceIndexPath.section]) - else { return nil } - + let sourceDistanceToTableViewTopEdge: CGFloat = { + if tableView.window != nil { + return tableView.convert(rectForSourceItemCell, to: nil).origin.y - tableView.safeAreaInsets.top + } else { + return rectForSourceItemCell.origin.y - tableView.contentOffset.y - tableView.safeAreaInsets.top + } + }() + let sectionIdentifier = oldSnapshot.sectionIdentifiers[sourceIndexPath.section] let item = oldSnapshot.itemIdentifiers(inSection: sectionIdentifier)[sourceIndexPath.row] - + guard let targetIndexPathRow = newSnapshot.indexOfItem(item), let newSectionIdentifier = newSnapshot.sectionIdentifier(containingItem: item), let targetIndexPathSection = newSnapshot.indexOfSection(newSectionIdentifier) else { return nil } - + let targetIndexPath = IndexPath(row: targetIndexPathRow, section: targetIndexPathSection) - + return Difference( item: item, sourceIndexPath: sourceIndexPath, @@ -280,3 +290,65 @@ extension StatusThreadViewModel { ) } } + +extension StatusThreadViewModel { + @MainActor func updateDataSource( + snapshot: NSDiffableDataSourceSnapshot, + animatingDifferences: Bool + ) { + diffableDataSource?.apply(snapshot, animatingDifferences: animatingDifferences) + } + + @MainActor func updateSnapshotUsingReloadData( + snapshot: NSDiffableDataSourceSnapshot + ) { + diffableDataSource?.applySnapshotUsingReloadData(snapshot) + } + + @MainActor func reloadSnapshotWithDifference( + tableView: UITableView, + oldSnapshot: NSDiffableDataSourceSnapshot, + newSnapshot: NSDiffableDataSourceSnapshot, + difference: Difference + ) { + tableView.isUserInteractionEnabled = false + tableView.panGestureRecognizer.isEnabled = false + defer { + tableView.isUserInteractionEnabled = true + tableView.panGestureRecognizer.isEnabled = true + } + diffableDataSource?.applySnapshotUsingReloadData(newSnapshot) + + guard let index = newSnapshot.indexOfItem(.root), + let lastItem = newSnapshot.itemIdentifiers.last, + let lastIndex = newSnapshot.indexOfItem(lastItem) + else { + return + } + + // fix contentOffset update delay issue + tableView.scrollToRow(at: difference.targetIndexPath, at: .top, animated: false) + tableView.layoutIfNeeded() + + let rectForCell = tableView.rectForRow(at: IndexPath(row: index, section: 0)) + let rectForLastCell = tableView.rectForRow(at: IndexPath(row: lastIndex, section: 0)) + let rectForTargetCell = tableView.rectForRow(at: difference.targetIndexPath) + + // always set bottom inset due to lazy reply loading + // otherwise tableView will jump when insert replies + let bottomSpacing = tableView.safeAreaLayoutGuide.layoutFrame.height - rectForCell.height - TimelineLoaderTableViewCell.cellHeight + let additionalInset = round(rectForLastCell.maxY - rectForCell.maxY) + let inset = bottomSpacing - max(0, additionalInset) + tableView.contentInset.bottom = max(0, inset) + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): content inset bottom: \(tableView.contentInset.bottom)") + + let contentOffsetY: CGFloat = { + var offset: CGFloat = rectForTargetCell.minY + offset -= tableView.safeAreaInsets.top + offset -= difference.sourceDistanceToTableViewTopEdge + return offset + }() + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): contentOffsetY: \(contentOffsetY)") + tableView.contentOffset.y = contentOffsetY + } +} diff --git a/TwidereX/Scene/StatusThread/StatusThreadViewModel+LoadThreadState.swift b/TwidereX/Scene/StatusThread/StatusThreadViewModel+LoadThreadState.swift index 740b6d2b..b46da2ae 100644 --- a/TwidereX/Scene/StatusThread/StatusThreadViewModel+LoadThreadState.swift +++ b/TwidereX/Scene/StatusThread/StatusThreadViewModel+LoadThreadState.swift @@ -12,465 +12,465 @@ import GameplayKit import CoreDataStack import TwitterSDK -extension StatusThreadViewModel { - class LoadThreadState: GKState, NamingState { - weak var viewModel: StatusThreadViewModel? - var name: String { "Base" } - - init(viewModel: StatusThreadViewModel) { - self.viewModel = viewModel - } - - override func didEnter(from previousState: GKState?) { - super.didEnter(from: previousState) - os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, name, (previousState as? NamingState)?.name ?? previousState?.description ?? "nil") - // guard let viewModel = viewModel, let stateMachine = stateMachine else { return } - } - } -} - -extension StatusThreadViewModel.LoadThreadState { - class Initial: StatusThreadViewModel.LoadThreadState { - override var name: String { "Initial" } - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return stateClass == Prepare.self - } - } - - class Prepare: StatusThreadViewModel.LoadThreadState { - override var name: String { "Prepare" } - let logger = Logger(subsystem: "StatusThreadViewModel.LoadThreadState", category: "Prepare") - - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return stateClass == Idle.self || stateClass == PrepareFail.self - } - - override func didEnter(from previousState: GKState?) { - super.didEnter(from: previousState) - - guard let viewModel = viewModel, let stateMachine = stateMachine else { return } - guard case let .root(threadContext) = viewModel.root.value else { - assertionFailure() - stateMachine.enter(PrepareFail.self) - return - } - - Task { - switch threadContext.status { - case .twitter(let record): - await prepareTwitterStatusThread(record: record) - case .mastodon(let record): - await prepareMastodonStatusThread(record: record) - } - } - } - +//extension StatusThreadViewModel { +// class LoadThreadState: GKState, NamingState { +// weak var viewModel: StatusThreadViewModel? +// var name: String { "Base" } +// +// init(viewModel: StatusThreadViewModel) { +// self.viewModel = viewModel +// } +// +// override func didEnter(from previousState: GKState?) { +// super.didEnter(from: previousState) +// os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, name, (previousState as? NamingState)?.name ?? previousState?.description ?? "nil") +// // guard let viewModel = viewModel, let stateMachine = stateMachine else { return } +// } +// } +//} +// +//extension StatusThreadViewModel.LoadThreadState { +// class Initial: StatusThreadViewModel.LoadThreadState { +// override var name: String { "Initial" } +// override func isValidNextState(_ stateClass: AnyClass) -> Bool { +// return stateClass == Prepare.self +// } +// } +// +// class Prepare: StatusThreadViewModel.LoadThreadState { +// override var name: String { "Prepare" } +// let logger = Logger(subsystem: "StatusThreadViewModel.LoadThreadState", category: "Prepare") +// +// override func isValidNextState(_ stateClass: AnyClass) -> Bool { +// return stateClass == Idle.self || stateClass == PrepareFail.self +// } +// +// override func didEnter(from previousState: GKState?) { +// super.didEnter(from: previousState) +// +// guard let viewModel = viewModel, let stateMachine = stateMachine else { return } +// guard case let .root(threadContext) = viewModel.root.value else { +// assertionFailure() +// stateMachine.enter(PrepareFail.self) +// return +// } +// +// Task { +// switch threadContext.status { +// case .twitter(let record): +// await prepareTwitterStatusThread(record: record) +// case .mastodon(let record): +// await prepareMastodonStatusThread(record: record) +// } +// } +// } +// // prepare ThreadContext // note: // The conversationID is V2 only API. // Needs query conversationID via V2 endpoint if the status persisted from V1 API. - func prepareTwitterStatusThread(record: ManagedObjectRecord) async { - guard let viewModel = viewModel, let stateMachine = stateMachine else { return } - - let managedObjectContext = viewModel.context.managedObjectContext - let _twitterConversation: StatusThreadViewModel.ThreadContext.TwitterConversation? = await managedObjectContext.perform { - guard let _status = record.object(in: managedObjectContext) else { return nil } - self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): resolve status \(_status.id) in local DB") - - // Note: - // make sure unwrap the repost wrapper - let status = _status.repost ?? _status - self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): resolve conversationID \(status.conversationID ?? "")") - return StatusThreadViewModel.ThreadContext.TwitterConversation( - statusID: status.id, - authorID: status.author.id, - authorUsername: status.author.username, - createdAt: status.createdAt, - conversationID: status.conversationID - ) - } - guard let twitterConversation = _twitterConversation else { - stateMachine.enter(PrepareFail.self) - return - } - - if twitterConversation.conversationID == nil { - // fetch conversationID if not exist - guard let authenticationContext = viewModel.context.authenticationService.activeAuthenticationContext?.twitterAuthenticationContext else { - await enter(state: PrepareFail.self) - return - } - do { - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetching conversationID of \(twitterConversation.statusID)...") - let response = try await viewModel.context.apiService.twitterStatus( - statusIDs: [twitterConversation.statusID], - authenticationContext: authenticationContext - ) - guard let conversationID = response.value.data?.first?.conversationID else { - // assertionFailure() - await enter(state: PrepareFail.self) - return - } - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch conversationID: \(conversationID)") - let newTwitterConversation = StatusThreadViewModel.ThreadContext.TwitterConversation( - statusID: twitterConversation.statusID, - authorID: twitterConversation.authorID, - authorUsername: twitterConversation.authorUsername, - createdAt: twitterConversation.createdAt, - conversationID: conversationID - ) - viewModel.threadContext.value = .twitter(newTwitterConversation) - await enter(state: Idle.self) - await enter(state: Loading.self) - } catch { - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch conversationID failure: \(error.localizedDescription)") - await enter(state: PrepareFail.self) - } - } else { - // use cached conversationID - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch cached conversationID: \(twitterConversation.conversationID ?? "")") - viewModel.threadContext.value = .twitter(twitterConversation) - await enter(state: Idle.self) - await enter(state: Loading.self) - } - } - - func prepareMastodonStatusThread(record: ManagedObjectRecord) async { - guard let viewModel = viewModel else { return } - - let managedObjectContext = viewModel.context.managedObjectContext - let _mastodonContext: StatusThreadViewModel.ThreadContext.MastodonContext? = await managedObjectContext.perform { - guard let _status = record.object(in: managedObjectContext) else { return nil } - - // Note: - // make sure unwrap the repost wrapper - let status = _status.repost ?? _status - return StatusThreadViewModel.ThreadContext.MastodonContext( - domain: status.domain, - contextID: status.id, - replyToStatusID: status.replyToStatusID - ) - } - - guard let mastodonContext = _mastodonContext else { - await enter(state: PrepareFail.self) - return - } - - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch cached contextID: \(mastodonContext.contextID)") - viewModel.threadContext.value = .mastodon(mastodonContext) - await enter(state: Idle.self) - await enter(state: Loading.self) - } - - @MainActor - func enter(state: StatusThreadViewModel.LoadThreadState.Type) { - stateMachine?.enter(state) - } - - } // end class Prepare { … } - - class PrepareFail: StatusThreadViewModel.LoadThreadState { - override var name: String { "PrepareFail" } - var prepareFailCount = 0 - - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return stateClass == Prepare.self || stateClass == Fail.self - } - - override func didEnter(from previousState: GKState?) { - super.didEnter(from: previousState) - - // retry 3 times - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in - guard let self = self else { return } - guard let stateMachine = self.stateMachine else { return } - - guard self.prepareFailCount < 3 else { - stateMachine.enter(Fail.self) - return - } - self.prepareFailCount += 1 - stateMachine.enter(Prepare.self) - } - } - } - - class Idle: StatusThreadViewModel.LoadThreadState { - override var name: String { "Idle" } - - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return stateClass == Loading.self - } - } - - class Loading: StatusThreadViewModel.LoadThreadState { - override var name: String { "Loading" } - - var needsFallback = false - - var maxID: String? // v1 - var nextToken: String? // v2 - - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - switch stateClass { - case is Idle.Type, is NoMore.Type: - return true - case is Fail.Type: - return true - default: - return false - } - } - - override func didEnter(from previousState: GKState?) { - super.didEnter(from: previousState) - - guard let viewModel = viewModel else { return } - guard let threadContext = viewModel.threadContext.value else { - assertionFailure() - return - } - - Task { - switch threadContext { - case .twitter(let twitterConversation): - if needsFallback { - let nodes = await fetchFallback(twitterConversation: twitterConversation) - await append(nodes: nodes) - } else { - let nodes = await fetch(twitterConversation: twitterConversation) - await append(nodes: nodes) - } - case .mastodon(let mastodonContext): - let response = await fetch(mastodonContext: mastodonContext) - await append(response: response) - } - } - } - - // TODO: group into `StatusListFetchViewModel` - // fetch thread via V2 API - func fetch( - twitterConversation: StatusThreadViewModel.ThreadContext.TwitterConversation - ) async -> [TwitterStatusThreadLeafViewModel.Node] { - guard let viewModel = viewModel, let stateMachine = stateMachine else { return [] } - guard let authenticationContext = viewModel.context.authenticationService.activeAuthenticationContext?.twitterAuthenticationContext, - let conversationID = twitterConversation.conversationID - else { - await enter(state: Fail.self) - return [] - } - - let sevenDaysAgo = Date(timeInterval: -((7 * 24 * 60 * 60) - (5 * 60)), since: Date()) - var sinceID: Twitter.Entity.V2.Tweet.ID? - var startTime: Date? - - if twitterConversation.createdAt < sevenDaysAgo { - startTime = sevenDaysAgo - } else { - sinceID = twitterConversation.statusID - } - - do { - let response = try await viewModel.context.apiService.searchTwitterStatus( - conversationID: conversationID, - authorID: twitterConversation.authorID, - sinceID: sinceID, - startTime: startTime, - nextToken: nextToken, - authenticationContext: authenticationContext - ) - let nodes = TwitterStatusThreadLeafViewModel.Node.children( - of: twitterConversation.statusID, - from: response.value - ) - - var hasMore = response.value.meta.resultCount != 0 - if let nextToken = response.value.meta.nextToken { - self.nextToken = nextToken - } else { - hasMore = false - } - - if hasMore { - await enter(state: Idle.self) - } else { - await enter(state: NoMore.self) - } - - return nodes - } catch let error as Twitter.API.Error.ResponseError where error.twitterAPIError == .rateLimitExceeded { - self.needsFallback = true - stateMachine.enter(Idle.self) - stateMachine.enter(Loading.self) - return [] - } catch { - await enter(state: Fail.self) - return [] - } - } - +// func prepareTwitterStatusThread(record: ManagedObjectRecord) async { +// guard let viewModel = viewModel, let stateMachine = stateMachine else { return } +// +// let managedObjectContext = viewModel.context.managedObjectContext +// let _twitterConversation: StatusThreadViewModel.ThreadContext.TwitterConversation? = await managedObjectContext.perform { +// guard let _status = record.object(in: managedObjectContext) else { return nil } +// self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): resolve status \(_status.id) in local DB") +// +// // Note: +// // make sure unwrap the repost wrapper +// let status = _status.repost ?? _status +// self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): resolve conversationID \(status.conversationID ?? "")") +// return StatusThreadViewModel.ThreadContext.TwitterConversation( +// statusID: status.id, +// authorID: status.author.id, +// authorUsername: status.author.username, +// createdAt: status.createdAt, +// conversationID: status.conversationID +// ) +// } +// guard let twitterConversation = _twitterConversation else { +// stateMachine.enter(PrepareFail.self) +// return +// } +// +// if twitterConversation.conversationID == nil { +// // fetch conversationID if not exist +// guard case let .twitter(authenticationContext) = viewModel.authContext.authenticationContext else { +// await enter(state: PrepareFail.self) +// return +// } +// do { +// logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetching conversationID of \(twitterConversation.statusID)...") +// let response = try await viewModel.context.apiService.twitterStatus( +// statusIDs: [twitterConversation.statusID], +// authenticationContext: authenticationContext +// ) +// guard let conversationID = response.value.data?.first?.conversationID else { +// // assertionFailure() +// await enter(state: PrepareFail.self) +// return +// } +// logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch conversationID: \(conversationID)") +// let newTwitterConversation = StatusThreadViewModel.ThreadContext.TwitterConversation( +// statusID: twitterConversation.statusID, +// authorID: twitterConversation.authorID, +// authorUsername: twitterConversation.authorUsername, +// createdAt: twitterConversation.createdAt, +// conversationID: conversationID +// ) +// viewModel.threadContext.value = .twitter(newTwitterConversation) +// await enter(state: Idle.self) +// await enter(state: Loading.self) +// } catch { +// logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch conversationID failure: \(error.localizedDescription)") +// await enter(state: PrepareFail.self) +// } +// } else { +// // use cached conversationID +// logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch cached conversationID: \(twitterConversation.conversationID ?? "")") +// viewModel.threadContext.value = .twitter(twitterConversation) +// await enter(state: Idle.self) +// await enter(state: Loading.self) +// } +// } +// +// func prepareMastodonStatusThread(record: ManagedObjectRecord) async { +// guard let viewModel = viewModel else { return } +// +// let managedObjectContext = viewModel.context.managedObjectContext +// let _mastodonContext: StatusThreadViewModel.ThreadContext.MastodonContext? = await managedObjectContext.perform { +// guard let _status = record.object(in: managedObjectContext) else { return nil } +// +// // Note: +// // make sure unwrap the repost wrapper +// let status = _status.repost ?? _status +// return StatusThreadViewModel.ThreadContext.MastodonContext( +// domain: status.domain, +// contextID: status.id, +// replyToStatusID: status.replyToStatusID +// ) +// } +// +// guard let mastodonContext = _mastodonContext else { +// await enter(state: PrepareFail.self) +// return +// } +// +// logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch cached contextID: \(mastodonContext.contextID)") +// viewModel.threadContext.value = .mastodon(mastodonContext) +// await enter(state: Idle.self) +// await enter(state: Loading.self) +// } +// +// @MainActor +// func enter(state: StatusThreadViewModel.LoadThreadState.Type) { +// stateMachine?.enter(state) +// } +// +// } // end class Prepare { … } +// +// class PrepareFail: StatusThreadViewModel.LoadThreadState { +// override var name: String { "PrepareFail" } +// var prepareFailCount = 0 +// +// override func isValidNextState(_ stateClass: AnyClass) -> Bool { +// return stateClass == Prepare.self || stateClass == Fail.self +// } +// +// override func didEnter(from previousState: GKState?) { +// super.didEnter(from: previousState) +// +// // retry 3 times +// DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in +// guard let self = self else { return } +// guard let stateMachine = self.stateMachine else { return } +// +// guard self.prepareFailCount < 3 else { +// stateMachine.enter(Fail.self) +// return +// } +// self.prepareFailCount += 1 +// stateMachine.enter(Prepare.self) +// } +// } +// } +// +// class Idle: StatusThreadViewModel.LoadThreadState { +// override var name: String { "Idle" } +// +// override func isValidNextState(_ stateClass: AnyClass) -> Bool { +// return stateClass == Loading.self +// } +// } +// +// class Loading: StatusThreadViewModel.LoadThreadState { +// override var name: String { "Loading" } +// +// var needsFallback = false +// +// var maxID: String? // v1 +// var nextToken: String? // v2 +// +// override func isValidNextState(_ stateClass: AnyClass) -> Bool { +// switch stateClass { +// case is Idle.Type, is NoMore.Type: +// return true +// case is Fail.Type: +// return true +// default: +// return false +// } +// } +// +// override func didEnter(from previousState: GKState?) { +// super.didEnter(from: previousState) +// +// guard let viewModel = viewModel else { return } +// guard let threadContext = viewModel.threadContext.value else { +// assertionFailure() +// return +// } +// +// Task { +// switch threadContext { +// case .twitter(let twitterConversation): +// if needsFallback { +// let nodes = await fetchFallback(twitterConversation: twitterConversation) +// await append(nodes: nodes) +// } else { +// let nodes = await fetch(twitterConversation: twitterConversation) +// await append(nodes: nodes) +// } +// case .mastodon(let mastodonContext): +// let response = await fetch(mastodonContext: mastodonContext) +// await append(response: response) +// } +// } +// } +// +// // TODO: group into `StatusListFetchViewModel` +// // fetch thread via V2 API +// func fetch( +// twitterConversation: StatusThreadViewModel.ThreadContext.TwitterConversation +// ) async -> [TwitterStatusThreadLeafViewModel.Node] { +// guard let viewModel = viewModel, let stateMachine = stateMachine else { return [] } +// guard case let .twitter(authenticationContext) = viewModel.authContext.authenticationContext, +// let conversationID = twitterConversation.conversationID +// else { +// await enter(state: Fail.self) +// return [] +// } +// +// let sevenDaysAgo = Date(timeInterval: -((7 * 24 * 60 * 60) - (5 * 60)), since: Date()) +// var sinceID: Twitter.Entity.V2.Tweet.ID? +// var startTime: Date? +// +// if twitterConversation.createdAt < sevenDaysAgo { +// startTime = sevenDaysAgo +// } else { +// sinceID = twitterConversation.statusID +// } +// +// do { +// let response = try await viewModel.context.apiService.searchTwitterStatus( +// conversationID: conversationID, +// authorID: twitterConversation.authorID, +// sinceID: sinceID, +// startTime: startTime, +// nextToken: nextToken, +// authenticationContext: authenticationContext +// ) +// let nodes = TwitterStatusThreadLeafViewModel.Node.children( +// of: twitterConversation.statusID, +// from: response.value +// ) +// +// var hasMore = response.value.meta.resultCount != 0 +// if let nextToken = response.value.meta.nextToken { +// self.nextToken = nextToken +// } else { +// hasMore = false +// } +// +// if hasMore { +// await enter(state: Idle.self) +// } else { +// await enter(state: NoMore.self) +// } +// +// return nodes +// } catch let error as Twitter.API.Error.ResponseError where error.twitterAPIError == .rateLimitExceeded { +// self.needsFallback = true +// stateMachine.enter(Idle.self) +// stateMachine.enter(Loading.self) +// return [] +// } catch { +// await enter(state: Fail.self) +// return [] +// } +// } +// // fetch thread via V1 API - func fetchFallback( - twitterConversation: StatusThreadViewModel.ThreadContext.TwitterConversation - ) async -> [TwitterStatusThreadLeafViewModel.Node] { - guard let viewModel = viewModel, let stateMachine = stateMachine else { return [] } - guard let authenticationContext = viewModel.context.authenticationService.activeAuthenticationContext?.twitterAuthenticationContext, - let _ = twitterConversation.conversationID - else { - await enter(state: Fail.self) - return [] - } - - do { - let response = try await viewModel.context.apiService.searchTwitterStatusV1( - conversationRootTweetID: twitterConversation.statusID, - authorUsername: twitterConversation.authorUsername, - maxID: maxID, - authenticationContext: authenticationContext - ) - let nodes = TwitterStatusThreadLeafViewModel.Node.children( - of: twitterConversation.statusID, - from: response.value - ) - - var hasMore = false - if let nextResult = response.value.searchMetadata.nextResults, - let components = URLComponents(string: nextResult), - let maxID = components.queryItems?.first(where: { $0.name == "max_id" })?.value, - maxID != self.maxID - { - self.maxID = maxID - hasMore = !(response.value.statuses ?? []).isEmpty - } - - if hasMore { - await enter(state: Idle.self) - } else { - await enter(state: NoMore.self) - } - - return nodes - } catch let error as Twitter.API.Error.ResponseError where error.twitterAPIError == .rateLimitExceeded { - self.needsFallback = true - stateMachine.enter(Idle.self) - stateMachine.enter(Loading.self) - return [] - } catch { - await enter(state: Fail.self) - return [] - } - } - - @MainActor - private func append(nodes: [TwitterStatusThreadLeafViewModel.Node]) async { - guard let viewModel = viewModel else { return } - viewModel.twitterStatusThreadLeafViewModel.append(nodes: nodes) - } - - @MainActor - private func append(response: MastodonContextResponse) async { - guard let viewModel = viewModel else { return } - - viewModel.mastodonStatusThreadViewModel.appendAncestor( - domain: response.domain, - nodes: response.ancestorNodes - ) - - viewModel.mastodonStatusThreadViewModel.appendDescendant( - domain: response.domain, - nodes: response.descendantNodes - ) - } - - struct MastodonContextResponse { - let domain: String - let ancestorNodes: [MastodonStatusThreadViewModel.Node] - let descendantNodes: [MastodonStatusThreadViewModel.Node] - } - - // fetch thread - func fetch( - mastodonContext: StatusThreadViewModel.ThreadContext.MastodonContext - ) async -> MastodonContextResponse { - guard let viewModel = viewModel else { - return MastodonContextResponse( - domain: "", - ancestorNodes: [], - descendantNodes: [] - ) - } - guard let authenticationContext = viewModel.context.authenticationService.activeAuthenticationContext?.mastodonAuthenticationContext - else { - await enter(state: Fail.self) - return MastodonContextResponse( - domain: "", - ancestorNodes: [], - descendantNodes: [] - ) - } - - do { - let response = try await viewModel.context.apiService.mastodonStatusContext( - statusID: mastodonContext.contextID, - authenticationContext: authenticationContext - ) - let ancestorNodes = MastodonStatusThreadViewModel.Node.replyToThread( - for: mastodonContext.replyToStatusID, - from: response.value.ancestors - ) - let descendantNodes = MastodonStatusThreadViewModel.Node.children( - of: mastodonContext.contextID, - from: response.value.descendants - ) - - // update state - await enter(state: NoMore.self) - - return MastodonContextResponse( - domain: mastodonContext.domain, - ancestorNodes: ancestorNodes, - descendantNodes: descendantNodes - ) - } catch { - await enter(state: Fail.self) - return MastodonContextResponse( - domain: "", - ancestorNodes: [], - descendantNodes: [] - ) - } - } - - @MainActor - func enter(state: StatusThreadViewModel.LoadThreadState.Type) { - stateMachine?.enter(state) - } - } - - class Fail: StatusThreadViewModel.LoadThreadState { - override var name: String { "Fail" } - - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return stateClass == Loading.self - } - - override func didEnter(from previousState: GKState?) { - super.didEnter(from: previousState) - } - } - - class NoMore: StatusThreadViewModel.LoadThreadState { - override var name: String { "NoMore" } - - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return false - } - - override func didEnter(from previousState: GKState?) { - super.didEnter(from: previousState) - } - } - -} +// func fetchFallback( +// twitterConversation: StatusThreadViewModel.ThreadContext.TwitterConversation +// ) async -> [TwitterStatusThreadLeafViewModel.Node] { +// guard let viewModel = viewModel, let stateMachine = stateMachine else { return [] } +// guard case let .twitter(authenticationContext) = viewModel.authContext.authenticationContext, +// let _ = twitterConversation.conversationID +// else { +// await enter(state: Fail.self) +// return [] +// } +// +// do { +// let response = try await viewModel.context.apiService.searchTwitterStatusV1( +// conversationRootTweetID: twitterConversation.statusID, +// authorUsername: twitterConversation.authorUsername, +// maxID: maxID, +// authenticationContext: authenticationContext +// ) +// let nodes = TwitterStatusThreadLeafViewModel.Node.children( +// of: twitterConversation.statusID, +// from: response.value +// ) +// +// var hasMore = false +// if let nextResult = response.value.searchMetadata.nextResults, +// let components = URLComponents(string: nextResult), +// let maxID = components.queryItems?.first(where: { $0.name == "max_id" })?.value, +// maxID != self.maxID +// { +// self.maxID = maxID +// hasMore = !(response.value.statuses ?? []).isEmpty +// } +// +// if hasMore { +// await enter(state: Idle.self) +// } else { +// await enter(state: NoMore.self) +// } +// +// return nodes +// } catch let error as Twitter.API.Error.ResponseError where error.twitterAPIError == .rateLimitExceeded { +// self.needsFallback = true +// stateMachine.enter(Idle.self) +// stateMachine.enter(Loading.self) +// return [] +// } catch { +// await enter(state: Fail.self) +// return [] +// } +// } +// +// @MainActor +// private func append(nodes: [TwitterStatusThreadLeafViewModel.Node]) async { +// guard let viewModel = viewModel else { return } +// viewModel.twitterStatusThreadLeafViewModel.append(nodes: nodes) +// } +// +// @MainActor +// private func append(response: MastodonContextResponse) async { +// guard let viewModel = viewModel else { return } +// +// viewModel.mastodonStatusThreadViewModel.appendAncestor( +// domain: response.domain, +// nodes: response.ancestorNodes +// ) +// +// viewModel.mastodonStatusThreadViewModel.appendDescendant( +// domain: response.domain, +// nodes: response.descendantNodes +// ) +// } +// +// struct MastodonContextResponse { +// let domain: String +// let ancestorNodes: [MastodonStatusThreadViewModel.Node] +// let descendantNodes: [MastodonStatusThreadViewModel.Node] +// } +// +// // fetch thread +// func fetch( +// mastodonContext: StatusThreadViewModel.ThreadContext.MastodonContext +// ) async -> MastodonContextResponse { +// guard let viewModel = viewModel else { +// return MastodonContextResponse( +// domain: "", +// ancestorNodes: [], +// descendantNodes: [] +// ) +// } +// guard case let .mastodon(authenticationContext) = viewModel.authContext.authenticationContext +// else { +// await enter(state: Fail.self) +// return MastodonContextResponse( +// domain: "", +// ancestorNodes: [], +// descendantNodes: [] +// ) +// } +// +// do { +// let response = try await viewModel.context.apiService.mastodonStatusContext( +// statusID: mastodonContext.contextID, +// authenticationContext: authenticationContext +// ) +// let ancestorNodes = MastodonStatusThreadViewModel.Node.replyToThread( +// for: mastodonContext.replyToStatusID, +// from: response.value.ancestors +// ) +// let descendantNodes = MastodonStatusThreadViewModel.Node.children( +// of: mastodonContext.contextID, +// from: response.value.descendants +// ) +// +// // update state +// await enter(state: NoMore.self) +// +// return MastodonContextResponse( +// domain: mastodonContext.domain, +// ancestorNodes: ancestorNodes, +// descendantNodes: descendantNodes +// ) +// } catch { +// await enter(state: Fail.self) +// return MastodonContextResponse( +// domain: "", +// ancestorNodes: [], +// descendantNodes: [] +// ) +// } +// } +// +// @MainActor +// func enter(state: StatusThreadViewModel.LoadThreadState.Type) { +// stateMachine?.enter(state) +// } +// } +// +// class Fail: StatusThreadViewModel.LoadThreadState { +// override var name: String { "Fail" } +// +// override func isValidNextState(_ stateClass: AnyClass) -> Bool { +// return stateClass == Loading.self +// } +// +// override func didEnter(from previousState: GKState?) { +// super.didEnter(from: previousState) +// } +// } +// +// class NoMore: StatusThreadViewModel.LoadThreadState { +// override var name: String { "NoMore" } +// +// override func isValidNextState(_ stateClass: AnyClass) -> Bool { +// return false +// } +// +// override func didEnter(from previousState: GKState?) { +// super.didEnter(from: previousState) +// } +// } +// +//} diff --git a/TwidereX/Scene/StatusThread/StatusThreadViewModel.swift b/TwidereX/Scene/StatusThread/StatusThreadViewModel.swift index f5f0437d..4139dc6c 100644 --- a/TwidereX/Scene/StatusThread/StatusThreadViewModel.swift +++ b/TwidereX/Scene/StatusThread/StatusThreadViewModel.swift @@ -16,151 +16,542 @@ import CoreData import CoreDataStack import TwidereCore +@MainActor final class StatusThreadViewModel { var disposeBag = Set() let logger = Logger(subsystem: "StatusThreadViewModel", category: "ViewModel") + @Published public var viewLayoutFrame = ViewLayoutFrame() + + let conversationRootTableViewCell = StatusTableViewCell() + + var fetchInitalConversationTask: AnyCancellable? + // input let context: AppContext - let twitterStatusThreadReplyViewModel: TwitterStatusThreadReplyViewModel - let twitterStatusThreadLeafViewModel: TwitterStatusThreadLeafViewModel - let mastodonStatusThreadViewModel: MastodonStatusThreadViewModel - let topListBatchFetchViewModel = ListBatchFetchViewModel(direction: .top) - let bottomListBatchFetchViewModel = ListBatchFetchViewModel(direction: .bottom) - let viewDidAppear = PassthroughSubject() + let authContext: AuthContext + let kind: Kind + + @Published var deleteStatusIDs = Set() +// let twitterStatusThreadReplyViewModel: TwitterStatusThreadReplyViewModel +// let twitterStatusThreadLeafViewModel: TwitterStatusThreadLeafViewModel +// let mastodonStatusThreadViewModel: MastodonStatusThreadViewModel +// let topListBatchFetchViewModel = ListBatchFetchViewModel(direction: .top) +// let bottomListBatchFetchViewModel = ListBatchFetchViewModel(direction: .bottom) +// let viewDidAppear = PassthroughSubject() + // output - var diffableDataSource: UITableViewDiffableDataSource? - var root: CurrentValueSubject - var threadContext = CurrentValueSubject(nil) - @Published var replies: [StatusItem] = [] - @Published var leafs: [StatusItem] = [] - @Published var hasReplyTo = false - - // thread - @MainActor private(set) lazy var loadThreadStateMachine: GKStateMachine = { - let stateMachine = GKStateMachine(states: [ - LoadThreadState.Initial(viewModel: self), - LoadThreadState.Prepare(viewModel: self), - LoadThreadState.PrepareFail(viewModel: self), - LoadThreadState.Idle(viewModel: self), - LoadThreadState.Loading(viewModel: self), - LoadThreadState.Fail(viewModel: self), - LoadThreadState.NoMore(viewModel: self), - - ]) - stateMachine.enter(LoadThreadState.Initial.self) - return stateMachine - }() + var diffableDataSource: UITableViewDiffableDataSource? + + @Published private(set) var status: StatusObject? + @Published private(set) var statusViewModel: StatusView.ViewModel? + + @Published var topPendingThreads: [Thread] = [] + @Published var topThreads: [Thread] = [] + @Published var bottomThreads: [Thread] = [] + + @Published var topCursor: Cursor = .none + @Published var bottomCursor: Cursor = .none + @Published var isLoadTop: Bool = false + @Published var isLoadBottom: Bool = false + + @Published var conversationLinkConfiguration: [StatusRecord: LinkConfiguration] = [:] - private init( + public init( context: AppContext, - optionalRoot: StatusItem.Thread? + authContext: AuthContext, + kind: Kind ) { self.context = context - self.twitterStatusThreadReplyViewModel = TwitterStatusThreadReplyViewModel(context: context) - self.twitterStatusThreadLeafViewModel = TwitterStatusThreadLeafViewModel(context: context) - self.mastodonStatusThreadViewModel = MastodonStatusThreadViewModel(context: context) - self.root = CurrentValueSubject(optionalRoot) + self.authContext = authContext + self.kind = kind // end init - viewDidAppear - .subscribe(twitterStatusThreadReplyViewModel.viewDidAppear) - .store(in: &disposeBag) - - // TODO: handle lazy thread loading - hasReplyTo = { - guard case let .root(threadContext) = optionalRoot else { return false } - guard let status = threadContext.status.object(in: context.managedObjectContext) else { return false } - switch status { - case .twitter(let _status): - let status = _status.repost ?? _status - return status.replyToStatusID != nil - case .mastodon(let _status): - let status = _status.repost ?? _status - return status.replyToStatusID != nil - } - }() + switch kind { + case .status(let status): + update(status: status) + case .twitter, .mastodon: + Task { + await fetch(kind: kind) + } // end Task + } - ManagedObjectObserver.observe(context: context.managedObjectContext) - .sink(receiveCompletion: { completion in - // do nohting - }, receiveValue: { [weak self] changes in + fetchInitalConversationTask = Timer.publish(every: 3, on: .main, in: .common) + .autoconnect() + .map { _ in () } + .prepend(()) + .sink { [weak self] in guard let self = self else { return } - - let objectIDs: [NSManagedObjectID] = changes.changeTypes.compactMap { changeType in - guard case let .delete(object) = changeType else { return nil } - return object.objectID + guard let status = self.status else { return } + guard self.topCursor.isNone && self.bottomCursor.isNone else { + self.fetchInitalConversationTask = nil + return } - - self.delete(objectIDs: objectIDs) - }) - .store(in: &disposeBag) - - Publishers.CombineLatest( - twitterStatusThreadReplyViewModel.$items, - mastodonStatusThreadViewModel.ancestors - ) - .map { $0 + $1 } - .assign(to: &$replies) + let record = status.asRecord + Task { + try await self.fetchConversation(status: record, cursor: .none) + } // end Task + } + // self.twitterStatusThreadReplyViewModel = TwitterStatusThreadReplyViewModel(context: context, authContext: authContext) +// self.twitterStatusThreadLeafViewModel = TwitterStatusThreadLeafViewModel(context: context) +// self.mastodonStatusThreadViewModel = MastodonStatusThreadViewModel(context: context) +// self.root = CurrentValueSubject(optionalRoot) - Publishers.CombineLatest( - twitterStatusThreadLeafViewModel.items, - mastodonStatusThreadViewModel.descendants - ) - .map { $0 + $1 } - .assign(to: &$leafs) +// viewDidAppear +// .subscribe(twitterStatusThreadReplyViewModel.viewDidAppear) +// .store(in: &disposeBag) +// +// // TODO: handle lazy thread loading +// hasReplyTo = { +// guard case let .root(threadContext) = optionalRoot else { return false } +// guard let status = threadContext.status.object(in: context.managedObjectContext) else { return false } +// switch status { +// case .twitter(let _status): +// let status = _status.repost ?? _status +// return status.replyToStatusID != nil +// case .mastodon(let _status): +// let status = _status.repost ?? _status +// return status.replyToStatusID != nil +// } +// }() +// +// ManagedObjectObserver.observe(context: context.managedObjectContext) +// .sink(receiveCompletion: { completion in +// // do nohting +// }, receiveValue: { [weak self] changes in +// guard let self = self else { return } +// +// let objectIDs: [NSManagedObjectID] = changes.changeTypes.compactMap { changeType in +// guard case let .delete(object) = changeType else { return nil } +// return object.objectID +// } +// +// self.delete(objectIDs: objectIDs) +// }) +// .store(in: &disposeBag) +// +// Publishers.CombineLatest( +// twitterStatusThreadReplyViewModel.$items, +// mastodonStatusThreadViewModel.ancestors +// ) +// .map { $0 + $1 } +// .assign(to: &$replies) +// +// Publishers.CombineLatest( +// twitterStatusThreadLeafViewModel.items, +// mastodonStatusThreadViewModel.descendants +// ) +// .map { $0 + $1 } +// .assign(to: &$leafs) } - convenience init( - context: AppContext, - root: StatusItem.Thread - ) { - self.init( - context: context, - optionalRoot: root +} + +extension StatusThreadViewModel { + enum Kind { + case status(StatusRecord) + case twitter(Twitter.Entity.V2.Tweet.ID) + case mastodon(domain: String, Mastodon.Entity.Status.ID) + } + + enum Thread: Hashable { + case selfThread(status: StatusRecord) + case conversationThread(components: [StatusRecord]) + } + + enum Cursor { + case none + case value(String) + case noMore + + var isNone: Bool { + switch self { + case .none: return true + default: return false + } + } + + var isNoMore: Bool { + switch self { + case .noMore: return true + default: return false + } + } + + var value: String? { + switch self { + case .value(let value): return value + default: return nil + } + } + } + + struct LinkConfiguration { + let isTopLinkDisplay: Bool + let isBottomLinkDisplay: Bool + } + + public enum Section: Hashable { + case main + } // end Section + + public enum Item: Hashable, DifferenceItem { + // case + case status(status: StatusRecord) + case root + case topLoader + case bottomLoader + + public static func == (lhs: StatusThreadViewModel.Item, rhs: StatusThreadViewModel.Item) -> Bool { + switch (lhs, rhs) { + case (.status(let lhs), .status(let rhs)): + return lhs.objectID == rhs.objectID + case (.root, .root): + return true + case (.topLoader, .topLoader): + return true + case (.bottomLoader, .bottomLoader): + return true + default: + return false + } + } + + public func hash(into hasher: inout Hasher) { + switch self { + case .status(let status): + hasher.combine(String(describing: Item.status.self)) + hasher.combine(status.objectID) + case .root: + hasher.combine(String(describing: Item.root.self)) + case .topLoader: + hasher.combine(String(describing: Item.topLoader.self)) + case .bottomLoader: + hasher.combine(String(describing: Item.bottomLoader.self)) + } + } + + public var isTransient: Bool { + switch self { + case .topLoader, .bottomLoader: return true + default: return false + } + } + } // end Item +} + +extension StatusThreadViewModel { + @MainActor + func update(status record: StatusRecord) { + guard let status = record.object(in: context.managedObjectContext) else { + assertionFailure() + return + } + self.status = status + + // setup link configuration for root + updateConversationRootLink(status: status) + + guard statusViewModel == nil else { return } + let _statusViewViewModel = StatusView.ViewModel( + status: status, + authContext: authContext, + kind: .conversationRoot, + delegate: conversationRootTableViewCell, + viewLayoutFramePublisher: $viewLayoutFrame ) + self.statusViewModel = _statusViewViewModel } + @MainActor + func fetch(kind: Kind) async { + guard status == nil else { return } + + do { + switch kind { + case .status: + return + case .twitter(let statusID): + guard let authenticationContext = authContext.authenticationContext.twitterAuthenticationContext else { return } + _ = try await context.apiService.twitterStatus( + statusIDs: [statusID], + authenticationContext: authenticationContext + ) + let request = TwitterStatus.sortedFetchRequest + request.predicate = TwitterStatus.predicate(id: statusID) + request.fetchLimit = 1 + guard let result = try context.managedObjectContext.fetch(request).first else { + return + } + update(status: .twitter(record: result.asRecrod)) + case .mastodon(let domain, let statusID): + guard let authenticationContext = authContext.authenticationContext.mastodonAuthenticationContext else { return } + _ = try await context.apiService.mastodonStatus( + statusID: statusID, + authenticationContext: authenticationContext + ) + let request = MastodonStatus.sortedFetchRequest + request.predicate = MastodonStatus.predicate(domain: domain, id: statusID) + request.fetchLimit = 1 + guard let result = try context.managedObjectContext.fetch(request).first else { + return + } + update(status: .mastodon(record: result.asRecrod)) + } + } catch { + try? await Task.sleep(nanoseconds: 3 * .second) + await fetch(kind: kind) + } + } + + @MainActor + func loadTop() async throws { + guard !isLoadTop else { return } + isLoadTop = true + defer { isLoadTop = false } + + guard let status = self.statusViewModel?.status?.asRecord else { return } + guard case .value(let cursor) = topCursor else { return } + try await fetchConversation(status: status, cursor: .value(cursor)) + } + + @MainActor + func loadBottom() async throws { + guard !isLoadBottom else { return } + isLoadBottom = true + defer { isLoadBottom = false } + + guard let status = self.statusViewModel?.status?.asRecord else { return } + guard case .value(let cursor) = bottomCursor else { return } + try await fetchConversation(status: status, cursor: .value(cursor)) + } + + @MainActor + func appendBottom(threads: [Thread]) { + var result = self.bottomThreads + result.append(contentsOf: threads) + self.bottomThreads = result + } + + @MainActor + func enqueueTop(threads: [Thread]) { + var result = self.topThreads + result.insert(contentsOf: threads, at: 0) + self.topThreads = result + } + } extension StatusThreadViewModel { - enum ThreadContext { - case twitter(TwitterConversation) - case mastodon(MastodonContext) + private func fetchConversation( + status: StatusRecord, + cursor: Cursor + ) async throws { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch conversation, cursor: \(String(describing: cursor))") + switch status { + case .twitter(let record): + try await fetchConversation(status: record, cursor: cursor) + case .mastodon(let record): + try await fetchConversation(status: record, cursor: cursor) + } + } + + @MainActor + private func fetchConversation( + status: ManagedObjectRecord, + cursor: Cursor + ) async throws { + guard let authenticationContext = authContext.authenticationContext.twitterAuthenticationContext else { return } + let _conversationRootStatusID: TwitterStatus.ID? = await context.managedObjectContext.perform { + guard let status = status.object(in: self.context.managedObjectContext) else { return nil } + let statusID = (status.repost ?? status).id // remove repost wrapper + return statusID + } + guard let conversationRootStatusID = _conversationRootStatusID else { + assertionFailure() + return + } + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch conversation for \(conversationRootStatusID), cursor: \(cursor.value ?? "")") + let response = try await context.apiService.twitterStatusConversation( + conversationRootStatusID: conversationRootStatusID, + query: .init(cursor: cursor.value), + authenticationContext: authenticationContext + ) - struct TwitterConversation { - let statusID: Twitter.Entity.V2.Tweet.ID - let authorID: Twitter.Entity.User.ID - let authorUsername: String - let createdAt: Date - - // V2 only - let conversationID: Twitter.Entity.V2.Tweet.ConversationID? + // update cursor + if let cursor = response.value.topCursor { + self.topCursor = .value(cursor) + } else { + self.topCursor = .noMore + } + if let cursor = response.value.bottomCursor { + self.bottomCursor = .value(cursor) + } else { + self.bottomCursor = .noMore } + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch conversation success, top cursor: \(response.value.topCursor ?? ""), bottom cursor: \(response.value.bottomCursor ?? "")") - struct MastodonContext { - let domain: String - let contextID: Mastodon.Entity.Status.ID - let replyToStatusID: Mastodon.Entity.Status.ID? + let statusDict: [Twitter.Entity.V2.Tweet.ID: ManagedObjectRecord] = { + var dict: [TwitterStatus.ID: ManagedObjectRecord] = [:] + let request = TwitterStatus.sortedFetchRequest + let statusIDs = response.value.statusIDs + request.predicate = TwitterStatus.predicate(ids: statusIDs) + let result = try? context.managedObjectContext.fetch(request) + for status in result ?? [] { + guard status.id != conversationRootStatusID else { continue } + dict[status.id] = status.asRecrod + } + return dict + }() + let topThreads: [Thread] = { + var threads: [Thread] = [] + for statusID in response.value.data.thread { + guard let status = statusDict[statusID] else { + continue + } + guard statusID != conversationRootStatusID else { + continue + } + threads.append(.selfThread(status: .twitter(record: status))) + } + return threads + }() + let bottomThreads: [Thread] = { + var threads: [Thread] = [] + for array in response.value.data.consersation { + let components = array + .compactMap { statusDict[$0] } + .map { StatusRecord.twitter(record: $0) } + guard !components.isEmpty else { + assertionFailure() + continue + } + threads.append(.conversationThread(components: components)) + } + return threads + }() + enqueueTop(threads: topThreads) + appendBottom(threads: bottomThreads) + + if topThreads.isEmpty && bottomThreads.isEmpty { + // trigger data source update + update(status: .twitter(record: status)) + } + } + + @MainActor + private func fetchConversation( + status: ManagedObjectRecord, + cursor: Cursor + ) async throws { + guard let authenticationContext = authContext.authenticationContext.mastodonAuthenticationContext else { return } + guard let conversationRootStatus = status.object(in: context.managedObjectContext) else { + assertionFailure() + return + } + let conversationRootStatusID = conversationRootStatus.id + + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch conversation for \(conversationRootStatusID), cursor: \(cursor.value ?? "")") + + let response = try await context.apiService.mastodonStatusContext( + statusID: conversationRootStatusID, + authenticationContext: authenticationContext + ) + + // update cursor + self.topCursor = .noMore + self.bottomCursor = .noMore + + let ancestorNodes = MastodonStatusThreadViewModel.Node.replyToThread( + for: conversationRootStatus.replyToStatusID, + from: response.value.ancestors + ) + let descendantNodes = MastodonStatusThreadViewModel.Node.children( + of: conversationRootStatusID, + from: response.value.descendants + ) + let statusDict: [Mastodon.Entity.Status.ID: ManagedObjectRecord] = { + var dict: [MastodonStatus.ID: ManagedObjectRecord] = [:] + let request = MastodonStatus.sortedFetchRequest + var statusIDs: [MastodonStatus.ID] = [] + statusIDs += ancestorNodes.map { $0.statusID } + statusIDs += descendantNodes + .map { node in [node.statusID, node.children.first?.statusID].compactMap { $0 } } + .flatMap { $0 } + request.predicate = MastodonStatus.predicate(domain: authenticationContext.domain, ids: statusIDs) + let result = try? context.managedObjectContext.fetch(request) + for status in result ?? [] { + guard status.id != conversationRootStatusID else { continue } + dict[status.id] = status.asRecrod + } + return dict + }() + let topThreads: [Thread] = { + var threads: [Thread] = [] + for node in ancestorNodes { + guard let record = statusDict[node.statusID] else { continue } + threads.append(.selfThread(status: .mastodon(record: record))) + } + return threads + }() + let bottomThreads: [Thread] = { + var threads: [Thread] = [] + for node in descendantNodes { + guard let record = statusDict[node.statusID] else { continue } + var components: [StatusRecord] = [] + // first tier + components.append(.mastodon(record: record)) + // second tier + if let child = node.children.first, let secondRecord = statusDict[child.statusID] { + components.append(.mastodon(record: secondRecord)) + } + threads.append(.conversationThread(components: components)) + } + return threads + }() + enqueueTop(threads: topThreads) + appendBottom(threads: bottomThreads) + + if topThreads.isEmpty && bottomThreads.isEmpty { + // trigger data source update + update(status: .mastodon(record: status)) } } } extension StatusThreadViewModel { - func delete(objectIDs: [NSManagedObjectID]) { - if let root = root.value, - case let .root(threadContext) = root, - objectIDs.contains(threadContext.status.objectID) - { - self.root.value = nil - self.twitterStatusThreadReplyViewModel.root = nil - } - - self.twitterStatusThreadReplyViewModel.delete(objectIDs: objectIDs) - self.twitterStatusThreadLeafViewModel.delete(objectIDs: objectIDs) - self.mastodonStatusThreadViewModel.delete(objectIDs: objectIDs) + private func updateConversationRootLink(status: StatusObject) { + switch status { + case .twitter(let status): + let hasReplyTo = (status.repost ?? status).replyToStatusID != nil + let linkConfiguration = LinkConfiguration( + isTopLinkDisplay: hasReplyTo, + isBottomLinkDisplay: false + ) + self.conversationLinkConfiguration[.twitter(record: status.asRecrod)] = linkConfiguration + case .mastodon(let status): + let hasReplyTo = (status.repost ?? status).replyToStatusID != nil + let linkConfiguration = LinkConfiguration( + isTopLinkDisplay: hasReplyTo, + isBottomLinkDisplay: false + ) + self.conversationLinkConfiguration[.mastodon(record: status.asRecrod)] = linkConfiguration + } } +// func delete(objectIDs: [NSManagedObjectID]) { +// if let root = root.value, +// case let .root(threadContext) = root, +// objectIDs.contains(threadContext.status.objectID) +// { +// self.root.value = nil +// self.twitterStatusThreadReplyViewModel.root = nil +// } +// +// self.twitterStatusThreadReplyViewModel.delete(objectIDs: objectIDs) +// self.twitterStatusThreadLeafViewModel.delete(objectIDs: objectIDs) +// self.mastodonStatusThreadViewModel.delete(objectIDs: objectIDs) +// } } diff --git a/TwidereX/Scene/StatusThread/Twitter/TwitterStatusThreadLeafViewModel.swift b/TwidereX/Scene/StatusThread/Twitter/TwitterStatusThreadLeafViewModel.swift index fbbef0a4..3802d4da 100644 --- a/TwidereX/Scene/StatusThread/Twitter/TwitterStatusThreadLeafViewModel.swift +++ b/TwidereX/Scene/StatusThread/Twitter/TwitterStatusThreadLeafViewModel.swift @@ -13,224 +13,224 @@ import CoreData import CoreDataStack import TwitterSDK -final class TwitterStatusThreadLeafViewModel { - - var disposeBag = Set() - - // input - let context: AppContext - @Published private(set) var deletedObjectIDs: Set = Set() - - // output - @Published private var _items: [StatusItem] = [] - let items = CurrentValueSubject<[StatusItem], Never>([]) - - init(context: AppContext) { - self.context = context - - Publishers.CombineLatest( - $_items, - $deletedObjectIDs - ) - .sink { [weak self] items, deletedObjectIDs in - guard let self = self else { return } - let newItems = items.filter { item in - switch item { - case .thread(let thread): - return !deletedObjectIDs.contains(thread.statusRecord.objectID) - default: - assertionFailure() - return false - } - } - self.items.value = newItems - } - .store(in: &disposeBag) - } - - deinit { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - } - -} - -extension TwitterStatusThreadLeafViewModel { - - // FIXME: handle node remove - func append(nodes: [Node]) { - let childrenIDs = nodes - .map { node in [node.statusID, node.children.first?.statusID].compactMap { $0 } } - .flatMap { $0 } - var dictionary: [TwitterStatus.ID: TwitterStatus] = [:] - do { - let request = TwitterStatus.sortedFetchRequest - request.predicate = TwitterStatus.predicate(ids: childrenIDs) - let statuses = try self.context.managedObjectContext.fetch(request) - for status in statuses { - dictionary[status.id] = status - } - } catch { - os_log("%{public}s[%{public}ld], %{public}s: fetch conversation fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - return - } - - var newItems: [StatusItem] = [] - for node in nodes { - guard let status = dictionary[node.statusID] else { continue } - // first tier - let record = ManagedObjectRecord(objectID: status.objectID) - let context = StatusItem.Thread.Context( - status: .twitter(record: record) - ) - let item = StatusItem.thread(.leaf(context: context)) - newItems.append(item) - - // second tier - if let child = node.children.first { - guard let secondaryStatus = dictionary[child.statusID] else { continue } - let secondaryRecord = ManagedObjectRecord(objectID: secondaryStatus.objectID) - let secondaryContext = StatusItem.Thread.Context( - status: .twitter(record: secondaryRecord), - displayUpperConversationLink: true - ) - let secondaryItem = StatusItem.thread(.leaf(context: secondaryContext)) - newItems.append(secondaryItem) - - // update first tier context - context.displayBottomConversationLink = true - } - } - - var items = self._items - for item in newItems { - guard !items.contains(item) else { continue } - items.append(item) - } - self._items = items - } - -} - -extension TwitterStatusThreadLeafViewModel { - class Node { - typealias ID = String - - let statusID: ID - let children: [Node] - - init(statusID: ID, children: [TwitterStatusThreadLeafViewModel.Node]) { - self.statusID = statusID - self.children = children - } - } -} - -extension TwitterStatusThreadLeafViewModel.Node { - // V1 - static func children( - of statusID: ID, - from content: Twitter.API.Search.Content - ) -> [TwitterStatusThreadLeafViewModel.Node] { - let statuses = content.statuses ?? [] - var dictionary: [ID: Twitter.Entity.Tweet] = [:] - var mapping: [ID: Set] = [:] - - for status in statuses { - dictionary[status.idStr] = status - guard let replyToID = status.inReplyToStatusIDStr else { continue } - if var set = mapping[replyToID] { - set.insert(status.idStr) - mapping[replyToID] = set - } else { - mapping[replyToID] = Set([status.idStr]) - } - } - - var children: [TwitterStatusThreadLeafViewModel.Node] = [] - let replies = Array(mapping[statusID] ?? Set()) - .compactMap { dictionary[$0] } - .sorted(by: { $0.createdAt > $1.createdAt }) - for reply in replies { - let child = child(of: reply.idStr, dictionary: dictionary, mapping: mapping) - children.append(child) - } - return children - } - - static func child( - of statusID: ID, - dictionary: [ID: Twitter.Entity.Tweet], - mapping: [ID: Set] - ) -> TwitterStatusThreadLeafViewModel.Node { - let childrenIDs = mapping[statusID] ?? [] - let children = Array(childrenIDs) - .compactMap { dictionary[$0] } - .sorted(by: { $0.createdAt > $1.createdAt }) - .map { status in child(of: status.idStr, dictionary: dictionary, mapping: mapping) } - return TwitterStatusThreadLeafViewModel.Node( - statusID: statusID, - children: children - ) - } - - // V2 - static func children( - of statusID: ID, - from content: Twitter.API.V2.Search.Content - ) -> [TwitterStatusThreadLeafViewModel.Node] { - let statuses = [content.data, content.includes?.tweets].compactMap { $0 }.flatMap { $0 } - var dictionary: [ID: Twitter.Entity.V2.Tweet] = [:] - var mapping: [ID: Set] = [:] - - for status in statuses { - dictionary[status.id] = status - guard let replyTo = status.referencedTweets?.first(where: { $0.type == .repliedTo }), - let replyToID = replyTo.id - else { continue } - - if var set = mapping[replyToID] { - set.insert(status.id) - mapping[replyToID] = set - } else { - mapping[replyToID] = Set([status.id]) - } - } - - var children: [TwitterStatusThreadLeafViewModel.Node] = [] - let replies = Array(mapping[statusID] ?? Set()) - .compactMap { dictionary[$0] } - .sorted(by: { $0.createdAt > $1.createdAt }) - for reply in replies { - let child = child(of: reply.id, dictionary: dictionary, mapping: mapping) - children.append(child) - } - return children - } - - static func child( - of statusID: ID, - dictionary: [ID: Twitter.Entity.V2.Tweet], - mapping: [ID: Set] - ) -> TwitterStatusThreadLeafViewModel.Node { - let childrenIDs = mapping[statusID] ?? [] - let children = Array(childrenIDs) - .compactMap { dictionary[$0] } - .sorted(by: { $0.createdAt > $1.createdAt }) - .map { status in child(of: status.id, dictionary: dictionary, mapping: mapping) } - return TwitterStatusThreadLeafViewModel.Node( - statusID: statusID, - children: children - ) - } - -} - -extension TwitterStatusThreadLeafViewModel { - func delete(objectIDs: [NSManagedObjectID]) { - var set = deletedObjectIDs - for objectID in objectIDs { - set.insert(objectID) - } - self.deletedObjectIDs = set - } -} +//final class TwitterStatusThreadLeafViewModel { +// +// var disposeBag = Set() +// +// // input +// let context: AppContext +// @Published private(set) var deletedObjectIDs: Set = Set() +// +// // output +// @Published private var _items: [StatusItem] = [] +// let items = CurrentValueSubject<[StatusItem], Never>([]) +// +// init(context: AppContext) { +// self.context = context +// +// Publishers.CombineLatest( +// $_items, +// $deletedObjectIDs +// ) +// .sink { [weak self] items, deletedObjectIDs in +// guard let self = self else { return } +// let newItems = items.filter { item in +// switch item { +// case .thread(let thread): +// return !deletedObjectIDs.contains(thread.statusRecord.objectID) +// default: +// assertionFailure() +// return false +// } +// } +// self.items.value = newItems +// } +// .store(in: &disposeBag) +// } +// +// deinit { +// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) +// } +// +//} +// +//extension TwitterStatusThreadLeafViewModel { +// +// // FIXME: handle node remove +// func append(nodes: [Node]) { +// let childrenIDs = nodes +// .map { node in [node.statusID, node.children.first?.statusID].compactMap { $0 } } +// .flatMap { $0 } +// var dictionary: [TwitterStatus.ID: TwitterStatus] = [:] +// do { +// let request = TwitterStatus.sortedFetchRequest +// request.predicate = TwitterStatus.predicate(ids: childrenIDs) +// let statuses = try self.context.managedObjectContext.fetch(request) +// for status in statuses { +// dictionary[status.id] = status +// } +// } catch { +// os_log("%{public}s[%{public}ld], %{public}s: fetch conversation fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) +// return +// } +// +// var newItems: [StatusItem] = [] +// for node in nodes { +// guard let status = dictionary[node.statusID] else { continue } +// // first tier +// let record = ManagedObjectRecord(objectID: status.objectID) +// let context = StatusItem.Thread.Context( +// status: .twitter(record: record) +// ) +// let item = StatusItem.thread(.leaf(context: context)) +// newItems.append(item) +// +// // second tier +// if let child = node.children.first { +// guard let secondaryStatus = dictionary[child.statusID] else { continue } +// let secondaryRecord = ManagedObjectRecord(objectID: secondaryStatus.objectID) +// let secondaryContext = StatusItem.Thread.Context( +// status: .twitter(record: secondaryRecord), +// displayUpperConversationLink: true +// ) +// let secondaryItem = StatusItem.thread(.leaf(context: secondaryContext)) +// newItems.append(secondaryItem) +// +// // update first tier context +// context.displayBottomConversationLink = true +// } +// } +// +// var items = self._items +// for item in newItems { +// guard !items.contains(item) else { continue } +// items.append(item) +// } +// self._items = items +// } +// +//} +// +//extension TwitterStatusThreadLeafViewModel { +// class Node { +// typealias ID = String +// +// let statusID: ID +// let children: [Node] +// +// init(statusID: ID, children: [TwitterStatusThreadLeafViewModel.Node]) { +// self.statusID = statusID +// self.children = children +// } +// } +//} +// +//extension TwitterStatusThreadLeafViewModel.Node { +// // V1 +// static func children( +// of statusID: ID, +// from content: Twitter.API.Search.Content +// ) -> [TwitterStatusThreadLeafViewModel.Node] { +// let statuses = content.statuses ?? [] +// var dictionary: [ID: Twitter.Entity.Tweet] = [:] +// var mapping: [ID: Set] = [:] +// +// for status in statuses { +// dictionary[status.idStr] = status +// guard let replyToID = status.inReplyToStatusIDStr else { continue } +// if var set = mapping[replyToID] { +// set.insert(status.idStr) +// mapping[replyToID] = set +// } else { +// mapping[replyToID] = Set([status.idStr]) +// } +// } +// +// var children: [TwitterStatusThreadLeafViewModel.Node] = [] +// let replies = Array(mapping[statusID] ?? Set()) +// .compactMap { dictionary[$0] } +// .sorted(by: { $0.createdAt > $1.createdAt }) +// for reply in replies { +// let child = child(of: reply.idStr, dictionary: dictionary, mapping: mapping) +// children.append(child) +// } +// return children +// } +// +// static func child( +// of statusID: ID, +// dictionary: [ID: Twitter.Entity.Tweet], +// mapping: [ID: Set] +// ) -> TwitterStatusThreadLeafViewModel.Node { +// let childrenIDs = mapping[statusID] ?? [] +// let children = Array(childrenIDs) +// .compactMap { dictionary[$0] } +// .sorted(by: { $0.createdAt > $1.createdAt }) +// .map { status in child(of: status.idStr, dictionary: dictionary, mapping: mapping) } +// return TwitterStatusThreadLeafViewModel.Node( +// statusID: statusID, +// children: children +// ) +// } +// +// // V2 +// static func children( +// of statusID: ID, +// from content: Twitter.API.V2.Search.Content +// ) -> [TwitterStatusThreadLeafViewModel.Node] { +// let statuses = [content.data, content.includes?.tweets].compactMap { $0 }.flatMap { $0 } +// var dictionary: [ID: Twitter.Entity.V2.Tweet] = [:] +// var mapping: [ID: Set] = [:] +// +// for status in statuses { +// dictionary[status.id] = status +// guard let replyTo = status.referencedTweets?.first(where: { $0.type == .repliedTo }), +// let replyToID = replyTo.id +// else { continue } +// +// if var set = mapping[replyToID] { +// set.insert(status.id) +// mapping[replyToID] = set +// } else { +// mapping[replyToID] = Set([status.id]) +// } +// } +// +// var children: [TwitterStatusThreadLeafViewModel.Node] = [] +// let replies = Array(mapping[statusID] ?? Set()) +// .compactMap { dictionary[$0] } +// .sorted(by: { $0.createdAt > $1.createdAt }) +// for reply in replies { +// let child = child(of: reply.id, dictionary: dictionary, mapping: mapping) +// children.append(child) +// } +// return children +// } +// +// static func child( +// of statusID: ID, +// dictionary: [ID: Twitter.Entity.V2.Tweet], +// mapping: [ID: Set] +// ) -> TwitterStatusThreadLeafViewModel.Node { +// let childrenIDs = mapping[statusID] ?? [] +// let children = Array(childrenIDs) +// .compactMap { dictionary[$0] } +// .sorted(by: { $0.createdAt > $1.createdAt }) +// .map { status in child(of: status.id, dictionary: dictionary, mapping: mapping) } +// return TwitterStatusThreadLeafViewModel.Node( +// statusID: statusID, +// children: children +// ) +// } +// +//} +// +//extension TwitterStatusThreadLeafViewModel { +// func delete(objectIDs: [NSManagedObjectID]) { +// var set = deletedObjectIDs +// for objectID in objectIDs { +// set.insert(objectID) +// } +// self.deletedObjectIDs = set +// } +//} diff --git a/TwidereX/Scene/StatusThread/Twitter/TwitterStatusThreadReplyViewModel+State.swift b/TwidereX/Scene/StatusThread/Twitter/TwitterStatusThreadReplyViewModel+State.swift index ce523049..e3662e26 100644 --- a/TwidereX/Scene/StatusThread/Twitter/TwitterStatusThreadReplyViewModel+State.swift +++ b/TwidereX/Scene/StatusThread/Twitter/TwitterStatusThreadReplyViewModel+State.swift @@ -12,269 +12,267 @@ import GameplayKit import CoreDataStack import TwitterSDK -extension TwitterStatusThreadReplyViewModel { - class State: GKState, NamingState { - weak var viewModel: TwitterStatusThreadReplyViewModel? - var name: String { "Base" } - - init(viewModel: TwitterStatusThreadReplyViewModel) { - self.viewModel = viewModel - } - - override func didEnter(from previousState: GKState?) { - super.didEnter(from: previousState) - os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, name, (previousState as? NamingState)?.name ?? previousState?.description ?? "nil") - } - - @MainActor - func enter(state: TwitterStatusThreadReplyViewModel.State.Type) { - stateMachine?.enter(state) - } - - @MainActor - func apply(nodes: [TwitterStatusReplyNode]) { - self.viewModel?.nodes = nodes - } - } -} - -extension TwitterStatusThreadReplyViewModel.State { - class Initial: TwitterStatusThreadReplyViewModel.State { - override var name: String { "Initial" } - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - guard let viewModel = viewModel else { return false } - guard viewModel.root != nil else { return false } - - return stateClass == Prepare.self || stateClass == NoMore.self - } - } - - class Prepare: TwitterStatusThreadReplyViewModel.State { - - let logger = Logger(subsystem: "StatusThreadViewModel.LoadReplyState", category: "StateMachine") - - override var name: String { "Prepare" } - - static let throat = 20 - var previousResolvedNodeCount: Int? = nil - - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return stateClass == Loading.self || stateClass == Idle.self || stateClass == NoMore.self - } - - override func didEnter(from previousState: GKState?) { - super.didEnter(from: previousState) - - guard let viewModel = viewModel, let stateMachine = stateMachine else { return } - guard let record = viewModel.root else { - assertionFailure() - stateMachine.enter(NoMore.self) - return - } - - Task { - var nextState: TwitterStatusThreadReplyViewModel.State.Type? - var nodes: [TwitterStatusThreadReplyViewModel.TwitterStatusReplyNode] = [] - let managedObjectContext = viewModel.context.backgroundManagedObjectContext - try await managedObjectContext.performChanges { - guard let _status = record.object(in: managedObjectContext) else { - assertionFailure() - return - } - let status = _status.repost ?? _status - - var replyToArray: [TwitterStatus] = [] - var replyToNext: TwitterStatus? = status.replyTo - while let next = replyToNext { - replyToArray.append(next) - replyToNext = next.replyTo - } - - var replyNodes: [TwitterStatusThreadReplyViewModel.TwitterStatusReplyNode] = [] - for replyTo in replyToArray { - let node = TwitterStatusThreadReplyViewModel.TwitterStatusReplyNode( - statusID: replyTo.id, - replyToStatusID: replyTo.replyToStatusID, - status: .success(.init(objectID: replyTo.objectID)) - ) - replyNodes.append(node) - } - - let last = replyToArray.last ?? status - if let replyToStatusID = last.replyToStatusID { - // have reply to pointer but not resolved - // check local database and update relationship - do { - let request = TwitterStatus.sortedFetchRequest - request.fetchLimit = 1 - request.predicate = TwitterStatus.predicate(id: replyToStatusID) - let _replyToStatus = try managedObjectContext.fetch(request).first - - if let replyToStatus = _replyToStatus { - // find replyTo in local database - // update the entity - last.update(replyTo: replyToStatus) - - // append entity node - let node = TwitterStatusThreadReplyViewModel.TwitterStatusReplyNode( - statusID: replyToStatusID, - replyToStatusID: replyToStatus.replyToStatusID, - status: .success(.init(objectID: replyToStatus.objectID)) - ) - replyNodes.append(node) - - // append next placeholder node - if let nextReplyToStatusID = replyToStatus.replyToStatusID { - let nextNode = TwitterStatusThreadReplyViewModel.TwitterStatusReplyNode( - statusID: nextReplyToStatusID, - replyToStatusID: nil, - status: .notDetermined - ) - replyNodes.append(nextNode) - } - - } else { - // not find replyTo in local database - // create notDetermined placeholder node - let node = TwitterStatusThreadReplyViewModel.TwitterStatusReplyNode( - statusID: replyToStatusID, - replyToStatusID: nil, - status: .notDetermined - ) - replyNodes.append(node) - } - - } catch { - assertionFailure(error.localizedDescription) - } // end do { … } catch { … } - } // end if let replyToStatusID = last.replyToStatusID { … } - - nodes = replyNodes - - let pendingNodes = replyNodes.filter { node in - switch node.status { - case .notDetermined, .fail: return true - case .success: return false - } - } - - let _nextState: TwitterStatusThreadReplyViewModel.State.Type - if pendingNodes.isEmpty { - _nextState = NoMore.self - } else { - if replyNodes.count > Prepare.throat { - // stop reply auto lookup - _nextState = Idle.self - } else { - let resolvedNodeCount = replyNodes.count - pendingNodes.count - if let previousResolvedNodeCount = self.previousResolvedNodeCount { - if previousResolvedNodeCount == resolvedNodeCount { - _nextState = Fail.self - } else { - _nextState = Loading.self - } - } else { - self.previousResolvedNodeCount = resolvedNodeCount - _nextState = Loading.self - } - } - } // end if … else … - nextState = _nextState - } // end try await managedObjectContext.performChanges - - guard let nextState = nextState else { - assertionFailure() - return - } - - // set nodes before state update - self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): prepare reply nodes: \(nodes.debugDescription)") - await apply(nodes: nodes) - - await enter(state: nextState) - - } // end Task - } // end didEnter - - } - - class Idle: TwitterStatusThreadReplyViewModel.State { - override var name: String { "Idle" } - - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return stateClass == Loading.self - } - } - - class Loading: TwitterStatusThreadReplyViewModel.State { - - let logger = Logger(subsystem: "StatusThreadViewModel.LoadReplyState", category: "StateMachine") - - override var name: String { "Loading" } - - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return stateClass == Prepare.self || stateClass == Idle.self || stateClass == Fail.self || stateClass == NoMore.self - } - - override func didEnter(from previousState: GKState?) { - super.didEnter(from: previousState) - - guard let viewModel = viewModel, let stateMachine = stateMachine else { return } - guard let authenticationContext = viewModel.context.authenticationService.activeAuthenticationContext, - case let .twitter(twitterAuthenticationContext) = authenticationContext - else { - return - } - - let replyNodes = viewModel.nodes - let pendingNodes = replyNodes.filter { node in - switch node.status { - case .notDetermined, .fail: return true - case .success: return false - } - } - - guard !pendingNodes.isEmpty else { - stateMachine.enter(NoMore.self) - return - } - - let statusIDs = pendingNodes.map { $0.statusID } - - Task { - do { - // the APIService will persist entities into database - _ = try await viewModel.context.apiService.twitterStatus( - statusIDs: statusIDs, - authenticationContext: twitterAuthenticationContext - ) - - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch reply success: \(statusIDs.debugDescription)") - await enter(state: Prepare.self) - } catch { - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch reply fail: \(error.localizedDescription)") - await enter(state: Fail.self) - // FIXME: needs retry logic here - } - } - } - - } // end class Loading - - class Fail: TwitterStatusThreadReplyViewModel.State { - override var name: String { "Fail" } - - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return false - } - } - - class NoMore: TwitterStatusThreadReplyViewModel.State { - override var name: String { "NoMore" } - - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return false - } - } - -} +//extension TwitterStatusThreadReplyViewModel { +// class State: GKState, NamingState { +// weak var viewModel: TwitterStatusThreadReplyViewModel? +// var name: String { "Base" } +// +// init(viewModel: TwitterStatusThreadReplyViewModel) { +// self.viewModel = viewModel +// } +// +// override func didEnter(from previousState: GKState?) { +// super.didEnter(from: previousState) +// os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, name, (previousState as? NamingState)?.name ?? previousState?.description ?? "nil") +// } +// +// @MainActor +// func enter(state: TwitterStatusThreadReplyViewModel.State.Type) { +// stateMachine?.enter(state) +// } +// +// @MainActor +// func apply(nodes: [TwitterStatusReplyNode]) { +// self.viewModel?.nodes = nodes +// } +// } +//} +// +//extension TwitterStatusThreadReplyViewModel.State { +// class Initial: TwitterStatusThreadReplyViewModel.State { +// override var name: String { "Initial" } +// override func isValidNextState(_ stateClass: AnyClass) -> Bool { +// guard let viewModel = viewModel else { return false } +// guard viewModel.root != nil else { return false } +// +// return stateClass == Prepare.self || stateClass == NoMore.self +// } +// } +// +// class Prepare: TwitterStatusThreadReplyViewModel.State { +// +// let logger = Logger(subsystem: "StatusThreadViewModel.LoadReplyState", category: "StateMachine") +// +// override var name: String { "Prepare" } +// +// static let throat = 20 +// var previousResolvedNodeCount: Int? = nil +// +// override func isValidNextState(_ stateClass: AnyClass) -> Bool { +// return stateClass == Loading.self || stateClass == Idle.self || stateClass == NoMore.self +// } +// +// override func didEnter(from previousState: GKState?) { +// super.didEnter(from: previousState) +// +// guard let viewModel = viewModel, let stateMachine = stateMachine else { return } +// guard let record = viewModel.root else { +// assertionFailure() +// stateMachine.enter(NoMore.self) +// return +// } +// +// Task { +// var nextState: TwitterStatusThreadReplyViewModel.State.Type? +// var nodes: [TwitterStatusThreadReplyViewModel.TwitterStatusReplyNode] = [] +// let managedObjectContext = viewModel.context.backgroundManagedObjectContext +// try await managedObjectContext.performChanges { +// guard let _status = record.object(in: managedObjectContext) else { +// assertionFailure() +// return +// } +// let status = _status.repost ?? _status +// +// var replyToArray: [TwitterStatus] = [] +// var replyToNext: TwitterStatus? = status.replyTo +// while let next = replyToNext { +// replyToArray.append(next) +// replyToNext = next.replyTo +// } +// +// var replyNodes: [TwitterStatusThreadReplyViewModel.TwitterStatusReplyNode] = [] +// for replyTo in replyToArray { +// let node = TwitterStatusThreadReplyViewModel.TwitterStatusReplyNode( +// statusID: replyTo.id, +// replyToStatusID: replyTo.replyToStatusID, +// status: .success(.init(objectID: replyTo.objectID)) +// ) +// replyNodes.append(node) +// } +// +// let last = replyToArray.last ?? status +// if let replyToStatusID = last.replyToStatusID { +// // have reply to pointer but not resolved +// // check local database and update relationship +// do { +// let request = TwitterStatus.sortedFetchRequest +// request.fetchLimit = 1 +// request.predicate = TwitterStatus.predicate(id: replyToStatusID) +// let _replyToStatus = try managedObjectContext.fetch(request).first +// +// if let replyToStatus = _replyToStatus { +// // find replyTo in local database +// // update the entity +// last.update(replyTo: replyToStatus) +// +// // append entity node +// let node = TwitterStatusThreadReplyViewModel.TwitterStatusReplyNode( +// statusID: replyToStatusID, +// replyToStatusID: replyToStatus.replyToStatusID, +// status: .success(.init(objectID: replyToStatus.objectID)) +// ) +// replyNodes.append(node) +// +// // append next placeholder node +// if let nextReplyToStatusID = replyToStatus.replyToStatusID { +// let nextNode = TwitterStatusThreadReplyViewModel.TwitterStatusReplyNode( +// statusID: nextReplyToStatusID, +// replyToStatusID: nil, +// status: .notDetermined +// ) +// replyNodes.append(nextNode) +// } +// +// } else { +// // not find replyTo in local database +// // create notDetermined placeholder node +// let node = TwitterStatusThreadReplyViewModel.TwitterStatusReplyNode( +// statusID: replyToStatusID, +// replyToStatusID: nil, +// status: .notDetermined +// ) +// replyNodes.append(node) +// } +// +// } catch { +// assertionFailure(error.localizedDescription) +// } // end do { … } catch { … } +// } // end if let replyToStatusID = last.replyToStatusID { … } +// +// nodes = replyNodes +// +// let pendingNodes = replyNodes.filter { node in +// switch node.status { +// case .notDetermined, .fail: return true +// case .success: return false +// } +// } +// +// let _nextState: TwitterStatusThreadReplyViewModel.State.Type +// if pendingNodes.isEmpty { +// _nextState = NoMore.self +// } else { +// if replyNodes.count > Prepare.throat { +// // stop reply auto lookup +// _nextState = Idle.self +// } else { +// let resolvedNodeCount = replyNodes.count - pendingNodes.count +// if let previousResolvedNodeCount = self.previousResolvedNodeCount { +// if previousResolvedNodeCount == resolvedNodeCount { +// _nextState = Fail.self +// } else { +// _nextState = Loading.self +// } +// } else { +// self.previousResolvedNodeCount = resolvedNodeCount +// _nextState = Loading.self +// } +// } +// } // end if … else … +// nextState = _nextState +// } // end try await managedObjectContext.performChanges +// +// guard let nextState = nextState else { +// assertionFailure() +// return +// } +// +// // set nodes before state update +// self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): prepare reply nodes: \(nodes.debugDescription)") +// await apply(nodes: nodes) +// +// await enter(state: nextState) +// +// } // end Task +// } // end didEnter +// +// } +// +// class Idle: TwitterStatusThreadReplyViewModel.State { +// override var name: String { "Idle" } +// +// override func isValidNextState(_ stateClass: AnyClass) -> Bool { +// return stateClass == Loading.self +// } +// } +// +// class Loading: TwitterStatusThreadReplyViewModel.State { +// +// let logger = Logger(subsystem: "StatusThreadViewModel.LoadReplyState", category: "StateMachine") +// +// override var name: String { "Loading" } +// +// override func isValidNextState(_ stateClass: AnyClass) -> Bool { +// return stateClass == Prepare.self || stateClass == Idle.self || stateClass == Fail.self || stateClass == NoMore.self +// } +// +// override func didEnter(from previousState: GKState?) { +// super.didEnter(from: previousState) +// +// guard let viewModel = viewModel, let stateMachine = stateMachine else { return } +// guard case let .twitter(twitterAuthenticationContext) = viewModel.authContext.authenticationContext else { +// return +// } +// +// let replyNodes = viewModel.nodes +// let pendingNodes = replyNodes.filter { node in +// switch node.status { +// case .notDetermined, .fail: return true +// case .success: return false +// } +// } +// +// guard !pendingNodes.isEmpty else { +// stateMachine.enter(NoMore.self) +// return +// } +// +// let statusIDs = pendingNodes.map { $0.statusID } +// +// Task { +// do { +// // the APIService will persist entities into database +// _ = try await viewModel.context.apiService.twitterStatus( +// statusIDs: statusIDs, +// authenticationContext: twitterAuthenticationContext +// ) +// +// logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch reply success: \(statusIDs.debugDescription)") +// await enter(state: Prepare.self) +// } catch { +// logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch reply fail: \(error.localizedDescription)") +// await enter(state: Fail.self) +// // FIXME: needs retry logic here +// } +// } +// } +// +// } // end class Loading +// +// class Fail: TwitterStatusThreadReplyViewModel.State { +// override var name: String { "Fail" } +// +// override func isValidNextState(_ stateClass: AnyClass) -> Bool { +// return false +// } +// } +// +// class NoMore: TwitterStatusThreadReplyViewModel.State { +// override var name: String { "NoMore" } +// +// override func isValidNextState(_ stateClass: AnyClass) -> Bool { +// return false +// } +// } +// +//} diff --git a/TwidereX/Scene/StatusThread/Twitter/TwitterStatusThreadReplyViewModel.swift b/TwidereX/Scene/StatusThread/Twitter/TwitterStatusThreadReplyViewModel.swift index 63edc846..e479f36d 100644 --- a/TwidereX/Scene/StatusThread/Twitter/TwitterStatusThreadReplyViewModel.swift +++ b/TwidereX/Scene/StatusThread/Twitter/TwitterStatusThreadReplyViewModel.swift @@ -15,120 +15,125 @@ import CoreData import CoreDataStack import TwidereCore -final class TwitterStatusThreadReplyViewModel { - - var disposeBag = Set() - - // input - let context: AppContext - @Published var root: ManagedObjectRecord? - @Published var nodes: [TwitterStatusReplyNode] = [] - @Published private(set) var deletedObjectIDs: Set = Set() - let viewDidAppear = PassthroughSubject() - - // output - @Published var items: [StatusItem] = [] - - @MainActor private(set) lazy var stateMachine: GKStateMachine = { - // exclude timeline middle fetcher state - let stateMachine = GKStateMachine(states: [ - State.Initial(viewModel: self), - State.Prepare(viewModel: self), - State.Idle(viewModel: self), - State.Loading(viewModel: self), - State.Fail(viewModel: self), - State.NoMore(viewModel: self), - - ]) - stateMachine.enter(State.Initial.self) - return stateMachine - }() - - init(context: AppContext) { - self.context = context - // end init - - Publishers.CombineLatest( - $root, - viewDidAppear.eraseToAnyPublisher() - ) - .receive(on: DispatchQueue.main) // <- required here due to state machine access $root value - .sink { [weak self] root, _ in - guard let self = self else { return } - guard root != nil else { return } - - Task { - if await self.stateMachine.currentState is State.Initial { - await self.stateMachine.enter(State.Prepare.self) - } - } - } - .store(in: &disposeBag) - - Publishers.CombineLatest( - $nodes, - $deletedObjectIDs - ) - .map { nodes, deletedObjectIDs in - var items: [StatusItem] = [] - for (i, node) in nodes.enumerated() { - guard case let .success(record) = node.status else { continue } - guard !deletedObjectIDs.contains(record.objectID) else { continue } - let isLast = i == nodes.count - 1 - let context = StatusItem.Thread.Context( - status: .twitter(record: record), - displayUpperConversationLink: !isLast, - displayBottomConversationLink: true - ) - let thread = StatusItem.Thread.reply(context: context) - items.append(.thread(thread)) - } - return items - } - .assign(to: &$items) - } - - deinit { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - } - -} - -extension TwitterStatusThreadReplyViewModel { - public class TwitterStatusReplyNode: CustomDebugStringConvertible { - let statusID: TwitterStatus.ID - let replyToStatusID: TwitterStatus.ID? - - let status: Status - - init( - statusID: TwitterStatus.ID, - replyToStatusID: TwitterStatus.ID?, - status: Status - ) { - self.statusID = statusID - self.replyToStatusID = replyToStatusID - self.status = status - } - - enum Status { - case notDetermined - case fail(Error) - case success(ManagedObjectRecord) - } - - public var debugDescription: String { - return "twitter status [\(statusID)] -> \(replyToStatusID ?? ""), \(status)" - } - } -} - -extension TwitterStatusThreadReplyViewModel { - func delete(objectIDs: [NSManagedObjectID]) { - var set = deletedObjectIDs - for objectID in objectIDs { - set.insert(objectID) - } - self.deletedObjectIDs = set - } -} +//final class TwitterStatusThreadReplyViewModel { +// +// var disposeBag = Set() +// +// // input +// let context: AppContext +// let authContext: AuthContext +// @Published var root: ManagedObjectRecord? +// @Published var nodes: [TwitterStatusReplyNode] = [] +// @Published private(set) var deletedObjectIDs: Set = Set() +// let viewDidAppear = PassthroughSubject() +// +// // output +// @Published var items: [StatusItem] = [] +// +// @MainActor private(set) lazy var stateMachine: GKStateMachine = { +// // exclude timeline middle fetcher state +// let stateMachine = GKStateMachine(states: [ +// State.Initial(viewModel: self), +// State.Prepare(viewModel: self), +// State.Idle(viewModel: self), +// State.Loading(viewModel: self), +// State.Fail(viewModel: self), +// State.NoMore(viewModel: self), +// +// ]) +// stateMachine.enter(State.Initial.self) +// return stateMachine +// }() +// +// init( +// context: AppContext, +// authContext: AuthContext +// ) { +// self.context = context +// self.authContext = authContext +// // end init +// +// Publishers.CombineLatest( +// $root, +// viewDidAppear.eraseToAnyPublisher() +// ) +// .receive(on: DispatchQueue.main) // <- required here due to state machine access $root value +// .sink { [weak self] root, _ in +// guard let self = self else { return } +// guard root != nil else { return } +// +// Task { +// if await self.stateMachine.currentState is State.Initial { +// await self.stateMachine.enter(State.Prepare.self) +// } +// } +// } +// .store(in: &disposeBag) +// +// Publishers.CombineLatest( +// $nodes, +// $deletedObjectIDs +// ) +// .map { nodes, deletedObjectIDs in +// var items: [StatusItem] = [] +// for (i, node) in nodes.enumerated() { +// guard case let .success(record) = node.status else { continue } +// guard !deletedObjectIDs.contains(record.objectID) else { continue } +// let isLast = i == nodes.count - 1 +// let context = StatusItem.Thread.Context( +// status: .twitter(record: record), +// displayUpperConversationLink: !isLast, +// displayBottomConversationLink: true +// ) +// let thread = StatusItem.Thread.reply(context: context) +// items.append(.thread(thread)) +// } +// return items +// } +// .assign(to: &$items) +// } +// +// deinit { +// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) +// } +// +//} +// +//extension TwitterStatusThreadReplyViewModel { +// public class TwitterStatusReplyNode: CustomDebugStringConvertible { +// let statusID: TwitterStatus.ID +// let replyToStatusID: TwitterStatus.ID? +// +// let status: Status +// +// init( +// statusID: TwitterStatus.ID, +// replyToStatusID: TwitterStatus.ID?, +// status: Status +// ) { +// self.statusID = statusID +// self.replyToStatusID = replyToStatusID +// self.status = status +// } +// +// enum Status { +// case notDetermined +// case fail(Error) +// case success(ManagedObjectRecord) +// } +// +// public var debugDescription: String { +// return "twitter status [\(statusID)] -> \(replyToStatusID ?? ""), \(status)" +// } +// } +//} +// +//extension TwitterStatusThreadReplyViewModel { +// func delete(objectIDs: [NSManagedObjectID]) { +// var set = deletedObjectIDs +// for objectID in objectIDs { +// set.insert(objectID) +// } +// self.deletedObjectIDs = set +// } +//} diff --git a/TwidereX/Scene/StubTimeline/StubTimelineCollectionViewCell.swift b/TwidereX/Scene/StubTimeline/StubTimelineCollectionViewCell.swift deleted file mode 100644 index 8536b012..00000000 --- a/TwidereX/Scene/StubTimeline/StubTimelineCollectionViewCell.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// StubTimelineCollectionViewCell.swift -// StubTimelineCollectionViewCell -// -// Created by Cirno MainasuK on 2021-8-2. -// Copyright © 2021 Twidere. All rights reserved. -// - -import UIKit - -final class StubTimelineCollectionViewCell: UICollectionViewCell { - - let primaryLabel = UILabel() - - override init(frame: CGRect) { - super.init(frame: frame) - _init() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } - -} - -extension StubTimelineCollectionViewCell { - - private func _init() { - primaryLabel.translatesAutoresizingMaskIntoConstraints = false - addSubview(primaryLabel) - NSLayoutConstraint.activate([ - primaryLabel.topAnchor.constraint(equalTo: topAnchor), - primaryLabel.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor), - primaryLabel.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor), - primaryLabel.bottomAnchor.constraint(equalTo: bottomAnchor), - primaryLabel.heightAnchor.constraint(equalToConstant: 44).priority(.required - 1), - ]) - } - -} - -extension StubTimelineCollectionViewCell { - struct ViewModel: Hashable { - let title: String - } -} diff --git a/TwidereX/Scene/StubTimeline/StubTimelineViewController.swift b/TwidereX/Scene/StubTimeline/StubTimelineViewController.swift deleted file mode 100644 index b9129388..00000000 --- a/TwidereX/Scene/StubTimeline/StubTimelineViewController.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// StubTimelineViewController.swift -// TwidereX -// -// Created by Cirno MainasuK on 2021-8-2. -// Copyright © 2021 Twidere. All rights reserved. -// - -import UIKit -import Combine - -final class StubTimelineViewController: UIViewController { - - var disposeBag = Set() - - private(set) lazy var viewModel = StubTimelineViewModel() - - private(set) lazy var collectionView: UICollectionView = { - let layoutConfiguration = UICollectionLayoutListConfiguration(appearance: .plain) - let layout = UICollectionViewCompositionalLayout.list(using: layoutConfiguration) - let collectionView = ContentOffsetFixedCollectionView(frame: .zero, collectionViewLayout: layout) - return collectionView - }() - - private(set) lazy var refreshControl = UIRefreshControl() - - - -} - -extension StubTimelineViewController { - - override func viewDidLoad() { - super.viewDidLoad() - - view.backgroundColor = .systemBackground - - collectionView.backgroundColor = .systemBackground - collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - collectionView.frame = view.bounds - view.addSubview(collectionView) - viewModel.setupDiffableDataSource(collectionView: collectionView) - - collectionView.refreshControl = refreshControl - refreshControl.addTarget(self, action: #selector(StubTimelineViewController.refreshControlValueDidChanged(_:)), for: .valueChanged) - viewModel.didLoadLatest - .receive(on: DispatchQueue.main) - .sink { [weak self] in - guard let self = self else { return } - self.refreshControl.endRefreshing() - } - .store(in: &disposeBag) - } - -} - -extension StubTimelineViewController { - @objc private func refreshControlValueDidChanged(_ sender: UIRefreshControl) { - Task(priority: .userInitiated) { - await self.viewModel.loadLatest() - } - } -} - diff --git a/TwidereX/Scene/StubTimeline/StubTimelineViewModel.swift b/TwidereX/Scene/StubTimeline/StubTimelineViewModel.swift deleted file mode 100644 index b0c791db..00000000 --- a/TwidereX/Scene/StubTimeline/StubTimelineViewModel.swift +++ /dev/null @@ -1,145 +0,0 @@ -// -// StubTimelineViewModel.swift -// StubTimelineViewModel -// -// Created by Cirno MainasuK on 2021-8-2. -// Copyright © 2021 Twidere. All rights reserved. -// - -import os.log -import UIKit -import Combine - -@MainActor -final class StubTimelineViewModel { - - let logger = Logger(subsystem: "StubTimelineViewModel", category: "Logic") - - // input - weak var collectionView: UICollectionView? - - // output - var diffableDataSource: UICollectionViewDiffableDataSource? - var didLoadLatest = PassthroughSubject() -} - -extension StubTimelineViewModel { - enum Section: Hashable { - case main - } - - enum Item: Hashable { - case stub(id: Int) - } - - func setupDiffableDataSource(collectionView: UICollectionView) { - self.collectionView = collectionView - diffableDataSource = StubTimelineViewModel.diffableDataSource(collectionView: collectionView) - - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.main]) - diffableDataSource?.apply(snapshot) - } - - static func diffableDataSource( - collectionView: UICollectionView - ) -> UICollectionViewDiffableDataSource { - let stubCellRegistration = UICollectionView.CellRegistration { cell, indexPath, viewModel in - cell.primaryLabel.text = viewModel.title - } - - return UICollectionViewDiffableDataSource( - collectionView: collectionView - ) { collectionView, indexPath, item in - switch item { - case .stub(let id): - let viewModel = StubTimelineCollectionViewCell.ViewModel(title: "\(id)") - return collectionView.dequeueConfiguredReusableCell(using: stubCellRegistration, for: indexPath, item: viewModel) - } - } - } - - struct Difference { - let item: T - let sourceIndexPath: IndexPath - let sourceDistanceToTop: CGFloat - let targetIndexPath: IndexPath - } - - private func calculateReloadSnapshotDifference( - collectionView: UICollectionView, - oldSnapshot: NSDiffableDataSourceSnapshot, - newSnapshot: NSDiffableDataSourceSnapshot - ) -> Difference? { - guard let sourceIndexPath = collectionView.indexPathsForVisibleItems.sorted().first else { return nil } - guard let layoutAttributes = collectionView.layoutAttributesForItem(at: sourceIndexPath) else { return nil } - - let sourceDistanceToTop = layoutAttributes.frame.origin.y - collectionView.bounds.origin.y - - guard sourceIndexPath.section < oldSnapshot.numberOfSections, - sourceIndexPath.row < oldSnapshot.numberOfItems(inSection: oldSnapshot.sectionIdentifiers[sourceIndexPath.section]) - else { return nil } - - let sectionIdentifier = oldSnapshot.sectionIdentifiers[sourceIndexPath.section] - let item = oldSnapshot.itemIdentifiers(inSection: sectionIdentifier)[sourceIndexPath.row] - - guard let targetIndexPathRow = newSnapshot.indexOfItem(item), - let newSectionIdentifier = newSnapshot.sectionIdentifier(containingItem: item), - let targetIndexPathSection = newSnapshot.indexOfSection(newSectionIdentifier) - else { return nil } - - let targetIndexPath = IndexPath(row: targetIndexPathRow, section: targetIndexPathSection) - - return Difference( - item: item, - sourceIndexPath: sourceIndexPath, - sourceDistanceToTop: sourceDistanceToTop, - targetIndexPath: targetIndexPath - ) - } - - func loadLatest() async { - guard let collectionView = self.collectionView else { return } - guard let diffableDataSource = self.diffableDataSource else { return } - let oldSnapshot = diffableDataSource.snapshot() - let count = oldSnapshot.numberOfItems - let start = count + 1 - - var items: [Item] = oldSnapshot.itemIdentifiers - let newItems = (start.. = { - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.main]) - snapshot.appendItems(items, toSection: .main) - return snapshot - }() - - await Task.sleep(2_000_000_000) // 2s - - let difference = calculateReloadSnapshotDifference(collectionView: collectionView, oldSnapshot: oldSnapshot, newSnapshot: newSnapshot) - - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - - let animatingDifferences = difference == nil - diffableDataSource.apply(newSnapshot, animatingDifferences: animatingDifferences) { - self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): applied new snapshot") - defer { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.33) { [weak self] in - self?.didLoadLatest.send() - } - } - guard let difference = difference else { return } - guard let layoutAttributes = collectionView.layoutAttributesForItem(at: difference.targetIndexPath) else { return } - let targetDistanceToTop = layoutAttributes.frame.origin.y - collectionView.bounds.origin.y - let offset = targetDistanceToTop - difference.sourceDistanceToTop - var contentOffset = collectionView.contentOffset - contentOffset.y += offset - collectionView.setContentOffset(contentOffset, animated: false) - } - } - } - -} diff --git a/TwidereX/Scene/Timeline/Base/Common/TimelineViewController.swift b/TwidereX/Scene/Timeline/Base/Common/TimelineViewController.swift index 5092b8f8..c903f944 100644 --- a/TwidereX/Scene/Timeline/Base/Common/TimelineViewController.swift +++ b/TwidereX/Scene/Timeline/Base/Common/TimelineViewController.swift @@ -10,9 +10,6 @@ import os.log import UIKit import Combine import Floaty -import AppShared -import TwidereCore -import TwidereUI import TabBarPager class TimelineViewController: UIViewController, NeedsDependency, DrawerSidebarTransitionHostViewController, MediaPreviewableViewController { @@ -90,6 +87,12 @@ extension TimelineViewController { .receive(on: DispatchQueue.main) .sink { [weak self] needsSetupAvatarBarButtonItem in guard let self = self else { return } + if let leftBarButtonItem = self.navigationItem.leftBarButtonItem, + leftBarButtonItem !== self.avatarBarButtonItem + { + // allow override + return + } self.navigationItem.leftBarButtonItem = needsSetupAvatarBarButtonItem ? self.avatarBarButtonItem : nil } .store(in: &disposeBag) @@ -98,17 +101,14 @@ extension TimelineViewController { avatarBarButtonItem.delegate = self // bind avatarBarButtonItem data - Publishers.CombineLatest( - context.authenticationService.$activeAuthenticationContext, - _viewModel.viewDidAppear.eraseToAnyPublisher() - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] authenticationContext, _ in - guard let self = self else { return } - let user = authenticationContext?.user(in: self.context.managedObjectContext) - self.avatarBarButtonItem.configure(user: user) - } - .store(in: &disposeBag) + _viewModel.viewDidAppear + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + let user = self._viewModel.authContext.authenticationContext.user(in: self.context.managedObjectContext) + self.avatarBarButtonItem.configure(user: user) + } + .store(in: &disposeBag) // layout publish progress publishProgressView.translatesAutoresizingMaskIntoConstraints = false @@ -170,15 +170,34 @@ extension TimelineViewController { _viewModel.viewDidAppear.send() } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + _viewModel.viewLayoutFrame.update(view: view) + } override func viewSafeAreaInsetsDidChange() { super.viewSafeAreaInsetsDidChange() + + _viewModel.viewLayoutFrame.update(view: view) DispatchQueue.main.async { [weak self] in guard let self = self else { return } self.floatyButton.paddingY = self.view.safeAreaInsets.bottom + UIView.floatyButtonBottomMargin } } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + coordinator.animate {[weak self] _ in + guard let self = self else { return } + self._viewModel.viewLayoutFrame.update(view: self.view) + } completion: { _ in + // do nothing + } + } } @@ -186,8 +205,8 @@ extension TimelineViewController { @objc private func avatarButtonPressed(_ sender: UIButton) { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") - let drawerSidebarViewModel = DrawerSidebarViewModel(context: context) - coordinator.present(scene: .drawerSidebar(viewModel: drawerSidebarViewModel), from: self, transition: .custom(transitioningDelegate: drawerSidebarTransitionController)) + let drawerSidebarViewModel = DrawerSidebarViewModel(context: context, authContext: authContext) + coordinator.present(scene: .drawerSidebar(viewModel: drawerSidebarViewModel), from: self, transition: .custom(animated: true, transitioningDelegate: drawerSidebarTransitionController)) } @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { @@ -195,7 +214,18 @@ extension TimelineViewController { Task { @MainActor in assert(self._viewModel != nil) + await self._viewModel.loadLatest() + + if self._viewModel.preferredTimelineResetToTop, + let scrollViewContainer = self as? ScrollViewContainer + { + await _ = try self._viewModel.didLoadLatest.eraseToAnyPublisher().singleOutput() + scrollViewContainer.scrollToTop( + animated: true, + option: .init(tryRefreshWhenStayAtTop: false) + ) + } } } @@ -204,6 +234,8 @@ extension TimelineViewController { let composeViewModel = ComposeViewModel(context: context) let composeContentViewModel = ComposeContentViewModel( + context: context, + authContext: authContext, kind: { switch _viewModel.kind { case .home: @@ -213,7 +245,6 @@ extension TimelineViewController { case .hashtag(let hashtag): return .hashtag(hashtag: hashtag) case .list: - assertionFailure("do not support post on list status") return .post case .search: assertionFailure("do not support post on search status") @@ -232,22 +263,17 @@ extension TimelineViewController { break } return settings - }(), - configurationContext: ComposeContentViewModel.ConfigurationContext( - apiService: context.apiService, - authenticationService: context.authenticationService, - mastodonEmojiService: context.mastodonEmojiService, - statusViewConfigureContext: .init( - dateTimeProvider: DateTimeSwiftProvider(), - twitterTextProvider: OfficialTwitterTextProvider(), - authenticationContext: context.authenticationService.$activeAuthenticationContext - ) - ) + }() ) coordinator.present(scene: .compose(viewModel: composeViewModel, contentViewModel: composeContentViewModel), from: self, transition: .modal(animated: true, completion: nil)) } } +// MARK: - AuthContextProvider +extension TimelineViewController: AuthContextProvider { + var authContext: AuthContext { _viewModel.authContext } +} + // MARK: - AvatarBarButtonItemDelegate extension TimelineViewController: AvatarBarButtonItemDelegate { } diff --git a/TwidereX/Scene/Timeline/Base/Common/TimelineViewModel+LoadOldestState.swift b/TwidereX/Scene/Timeline/Base/Common/TimelineViewModel+LoadOldestState.swift index fb6b80b8..9eeaeb50 100644 --- a/TwidereX/Scene/Timeline/Base/Common/TimelineViewModel+LoadOldestState.swift +++ b/TwidereX/Scene/Timeline/Base/Common/TimelineViewModel+LoadOldestState.swift @@ -71,6 +71,8 @@ extension TimelineViewModel.LoadOldestState { class Loading: TimelineViewModel.LoadOldestState { var nextInput: StatusFetchViewModel.Timeline.Input? + var retryCount = 0 + var nonce = UUID() override func isValidNextState(_ stateClass: AnyClass) -> Bool { switch stateClass { @@ -90,15 +92,24 @@ extension TimelineViewModel.LoadOldestState { override func didEnter(from previousState: GKState?) { super.didEnter(from: previousState) - // reset when reloading + // reset nextInput when reloading switch previousState { case is Reloading: nextInput = nil default: break } + + // reset fail count if needs + switch previousState { + case is Fail: + retryCount += 1 + default: + retryCount = 0 + } guard let viewModel = viewModel, let _ = stateMachine else { return } + let nonce = self.nonce Task { let managedObjectContext = viewModel.context.managedObjectContext @@ -107,13 +118,20 @@ extension TimelineViewModel.LoadOldestState { case .home: guard let record = viewModel.feedFetchedResultsController.records.last else { return nil } guard let feed = record.object(in: managedObjectContext) else { return nil } - return feed.statusObject?.asRecord + guard case let .status(status) = feed.content else { return nil } + return status.asRecord case .public, .hashtag, .list, .search, .user: guard let status = viewModel.statusRecordFetchedResultController.records.last else { return nil } return status } } - + + if retryCount > 0 { + let delay = min(64.0, pow(2.0, Double(retryCount))) + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Loading] restore loading from fail case with delay: \(delay, format: .fixed(precision: 2))s") + try? await Task.sleep(nanoseconds: UInt64(delay) * .second) + } + guard nonce == self.nonce else { return } await fetch(anchor: _anchorRecord) } // end Task } // end func @@ -121,10 +139,7 @@ extension TimelineViewModel.LoadOldestState { private func fetch(anchor record: StatusRecord?) async { guard let viewModel = viewModel, let _ = stateMachine else { return } - guard let authenticationContext = viewModel.context.authenticationService.activeAuthenticationContext else { - enter(state: Fail.self) - return - } + let authenticationContext = viewModel.authContext.authenticationContext if nextInput == nil { let managedObjectContext = viewModel.context.managedObjectContext @@ -133,10 +148,8 @@ extension TimelineViewModel.LoadOldestState { authenticationContext: authenticationContext, kind: viewModel.kind, position: { - guard let record = record else { - return .top(anchor: nil) - } - return .bottom(anchor: record) + // always reload at top when nextInput is nil + return .top(anchor: nil) }(), filter: StatusFetchViewModel.Timeline.Filter(rule: .empty) ) @@ -183,11 +196,19 @@ extension TimelineViewModel.LoadOldestState { case .twitterV2(let statuses): let statusIDs = statuses.map { $0.id } viewModel.statusRecordFetchedResultController.twitterStatusFetchedResultController.append(statusIDs: statusIDs) + case .twitterIDs(let statusIDs): + viewModel.statusRecordFetchedResultController.twitterStatusFetchedResultController.append(statusIDs: statusIDs) case .mastodon(let statuses): let statusIDs = statuses.map { $0.id } viewModel.statusRecordFetchedResultController.mastodonStatusFetchedResultController.append(statusIDs: statusIDs) } } + + } catch let error as EmptyState { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch failure: \(error.localizedDescription)") + enter(state: NoMore.self) + viewModel.emptyState = error + viewModel.statusRecordFetchedResultController.reload() } catch { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch failure: \(error.localizedDescription)") enter(state: Fail.self) diff --git a/TwidereX/Scene/Timeline/Base/Common/TimelineViewModel.swift b/TwidereX/Scene/Timeline/Base/Common/TimelineViewModel.swift index b9e58604..22a8b3b0 100644 --- a/TwidereX/Scene/Timeline/Base/Common/TimelineViewModel.swift +++ b/TwidereX/Scene/Timeline/Base/Common/TimelineViewModel.swift @@ -12,6 +12,8 @@ import Combine import CoreDataStack import GameplayKit import TwidereCore +import TwitterSDK +import func QuartzCore.CACurrentMediaTime class TimelineViewModel: TimelineViewModelDriver { @@ -24,21 +26,35 @@ class TimelineViewModel: TimelineViewModelDriver { // input let context: AppContext + let authContext: AuthContext let kind: StatusFetchViewModel.Timeline.Kind let feedFetchedResultsController: FeedFetchedResultsController let statusRecordFetchedResultController: StatusRecordFetchedResultController let listBatchFetchViewModel = ListBatchFetchViewModel() let viewDidAppear = CurrentValueSubject(Void()) + @Published public var viewLayoutFrame = ViewLayoutFrame() + @Published var enableAutoFetchLatest = false + @Published var didAutoFetchLatest = false @Published var isRefreshControlEnabled = true @Published var isFloatyButtonDisplay = true @Published var isLoadingLatest = false - @Published var lastAutomaticFetchTimestamp: Date? + + @Published private(set) var timelineRefreshInterval = UserDefaults.shared.timelineRefreshInterval + @Published private(set) var preferredTimelineResetToTop = UserDefaults.shared.preferredTimelineResetToTop // output - // @Published var snapshot = NSDiffableDataSourceSnapshot() let didLoadLatest = PassthroughSubject() + @Published var emptyState: EmptyState? + + // auto fetch + private var autoFetchLatestActionTime = CACurrentMediaTime() + let autoFetchLatestAction = PassthroughSubject() + let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) + .autoconnect() + .share() + .eraseToAnyPublisher() // bottom loader @MainActor private(set) lazy var stateMachine: GKStateMachine = { @@ -57,13 +73,40 @@ class TimelineViewModel: TimelineViewModelDriver { init( context: AppContext, + authContext: AuthContext, kind: StatusFetchViewModel.Timeline.Kind ) { self.context = context + self.authContext = authContext self.kind = kind self.feedFetchedResultsController = FeedFetchedResultsController(managedObjectContext: context.managedObjectContext) self.statusRecordFetchedResultController = StatusRecordFetchedResultController(managedObjectContext: context.managedObjectContext) + super.init() // end init + + UserDefaults.shared.publisher(for: \.timelineRefreshInterval) + .removeDuplicates() + .assign(to: &$timelineRefreshInterval) + + UserDefaults.shared.publisher(for: \.preferredTimelineResetToTop) + .removeDuplicates() + .assign(to: &$preferredTimelineResetToTop) + + timestampUpdatePublisher + .sink { [weak self] _ in + guard let self = self else { return } + guard self.enableAutoFetchLatest else { return } + let now = CACurrentMediaTime() + let elapse = now - self.autoFetchLatestActionTime + guard elapse > self.timelineRefreshInterval.seconds else { + let remains = self.timelineRefreshInterval.seconds - elapse + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): (\(String(describing: self)) auto fetch in \(remains, format: .fixed(precision: 2))s") + return + } + self.autoFetchLatestActionTime = now + self.autoFetchLatestAction.send() + } + .store(in: &disposeBag) } } @@ -79,6 +122,8 @@ extension TimelineViewModel { case .twitterV2(let array): let statusIDs = array.map { $0.id } statusRecordFetchedResultController.twitterStatusFetchedResultController.prepend(statusIDs: statusIDs) + case .twitterIDs(let statusIDs): + statusRecordFetchedResultController.twitterStatusFetchedResultController.prepend(statusIDs: statusIDs) case .mastodon(let array): let statusIDs = array.map { $0.id } statusRecordFetchedResultController.mastodonStatusFetchedResultController.prepend(statusIDs: statusIDs) @@ -97,10 +142,9 @@ extension TimelineViewModel { isLoadingLatest = false } - guard let authenticationContext = context.authenticationService.activeAuthenticationContext else { return } let fetchContext = StatusFetchViewModel.Timeline.FetchContext( managedObjectContext: context.managedObjectContext, - authenticationContext: authenticationContext, + authenticationContext: authContext.authenticationContext, kind: kind, position: { switch kind { @@ -109,7 +153,8 @@ extension TimelineViewModel { let anchor: StatusRecord? = { guard let record = feedFetchedResultsController.records.first else { return nil } guard let feed = record.object(in: managedObjectContext) else { return nil } - return feed.statusObject?.asRecord + guard case let .status(status) = feed.content else { return nil } + return status.asRecord }() return .top(anchor: anchor) case .public, .hashtag, .list: @@ -118,9 +163,11 @@ extension TimelineViewModel { assertionFailure("do not support refresh for search") return .top(anchor: nil) case .user: - // FIXME: use anchor with minID or reset the data source - // the like timeline gap may missing - return .top(anchor: nil) + let anchor: StatusRecord? = { + guard let record = statusRecordFetchedResultController.records.first else { return nil } + return record + }() + return .top(anchor: anchor) } }(), filter: StatusFetchViewModel.Timeline.Filter(rule: .empty) @@ -141,6 +188,14 @@ extension TimelineViewModel { case .public, .hashtag, .list, .search, .user: await prepend(result: output.result) } + } catch let error as Twitter.API.Error.ResponseError where error.twitterAPIError == .rateLimitExceeded { + self.didLoadLatest.send() + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(error.localizedDescription)") + context.apiService.error.send(.explicit(.twitterResponseError(error))) + } catch let error as EmptyState { + self.didLoadLatest.send() + self.emptyState = error + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(error.localizedDescription)") } catch { self.didLoadLatest.send() logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(error.localizedDescription)") diff --git a/TwidereX/Scene/Timeline/Base/Grid/GridTimelineViewController.swift b/TwidereX/Scene/Timeline/Base/Grid/GridTimelineViewController.swift index 2de151ff..ac78564c 100644 --- a/TwidereX/Scene/Timeline/Base/Grid/GridTimelineViewController.swift +++ b/TwidereX/Scene/Timeline/Base/Grid/GridTimelineViewController.swift @@ -128,66 +128,12 @@ extension GridTimelineViewController { // MARK: - UICollectionViewDelegate extension GridTimelineViewController: UICollectionViewDelegate { - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): did select \(indexPath.debugDescription)") - guard let cell = collectionView.cellForItem(at: indexPath) as? StatusMediaGalleryCollectionCell else { return } - Task { - let source = DataSourceItem.Source(collectionViewCell: nil, indexPath: indexPath) - 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 - } - await DataSourceFacade.coordinateToMediaPreviewScene( - provider: self, - target: .status, - status: status, - mediaPreviewContext: DataSourceFacade.MediaPreviewContext( - containerView: .mediaView(cell.mediaView), - mediaView: cell.mediaView, - index: 0 // <-- only one attachment - ) - ) - } - } + } // MARK: - StatusMediaGalleryCollectionCellDelegate extension GridTimelineViewController: StatusMediaGalleryCollectionCellDelegate { - func statusMediaGalleryCollectionCell(_ cell: StatusMediaGalleryCollectionCell, coverFlowCollectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - Task { - let source = DataSourceItem.Source(collectionViewCell: 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 - } - - guard let cell = coverFlowCollectionView.cellForItem(at: indexPath) as? CoverFlowStackMediaCollectionCell else { - assertionFailure() - return - } - - await DataSourceFacade.coordinateToMediaPreviewScene( - provider: self, - target: .status, - status: status, - mediaPreviewContext: DataSourceFacade.MediaPreviewContext( - containerView: .mediaView(cell.mediaView), - mediaView: cell.mediaView, - index: indexPath.row - ) - ) - } - } - - func statusMediaGalleryCollectionCell(_ cell: StatusMediaGalleryCollectionCell, toggleContentWarningOverlayViewDisplay contentWarningOverlayView: ContentWarningOverlayView) { + func statusMediaGalleryCollectionCell(_ cell: StatusMediaGalleryCollectionCell, mediaStackContainerViewModel: TwidereUI.MediaStackContainerView.ViewModel, didSelectMediaView mediaViewModel: TwidereUI.MediaView.ViewModel) { Task { let source = DataSourceItem.Source(collectionViewCell: cell, indexPath: nil) guard let item = await item(from: source) else { @@ -198,13 +144,11 @@ extension GridTimelineViewController: StatusMediaGalleryCollectionCellDelegate { assertionFailure("only works for status data provider") return } - - try await DataSourceFacade.responseToToggleMediaSensitiveAction( + await DataSourceFacade.coordinateToStatusThreadScene( provider: self, - target: .status, - status: status + kind: .status(status) ) - } + } // end Task } } diff --git a/TwidereX/Scene/Timeline/Base/List/ListTimelineViewController.swift b/TwidereX/Scene/Timeline/Base/List/ListTimelineViewController.swift index cec59007..248ce72e 100644 --- a/TwidereX/Scene/Timeline/Base/List/ListTimelineViewController.swift +++ b/TwidereX/Scene/Timeline/Base/List/ListTimelineViewController.swift @@ -8,10 +8,9 @@ import os.log import UIKit +import SwiftUI import Combine import Floaty -import AppShared -import TwidereCore import TabBarPager class ListTimelineViewController: TimelineViewController { @@ -24,11 +23,15 @@ class ListTimelineViewController: TimelineViewController { _viewModel = newValue } } + let emptyStateViewModel = EmptyStateView.ViewModel() + private(set) lazy var tableView: UITableView = { let tableView = UITableView() tableView.backgroundColor = .systemBackground tableView.rowHeight = UITableView.automaticDimension tableView.separatorStyle = .none + tableView.selfSizingInvalidation = .enabled + tableView.cellLayoutMarginsFollowReadableWidth = true return tableView }() @@ -72,11 +75,53 @@ extension ListTimelineViewController { .receive(on: DispatchQueue.main) .sink { [weak self] _ in guard let self = self else { return } + // do not fetch more when on background guard self.isDisplaying else { return } + + switch self.viewModel.kind { + case .home: + // do not fetch more when empty on home timeline + // otherwise the fetchLatest will be override at app launch + guard let snapshot = self.viewModel.diffableDataSource?.snapshot(), + snapshot.numberOfItems > 0 + else { return } + default: + break + } + self.viewModel.stateMachine.enter(TimelineViewModel.LoadOldestState.Loading.self) } .store(in: &disposeBag) + viewModel.autoFetchLatestAction + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + self.autoFetchLatest() + } + .store(in: &disposeBag) + + viewModel.$emptyState + .assign(to: \.emptyState, on: emptyStateViewModel) + .store(in: &disposeBag) + + let emptyStateViewHostingController = UIHostingController(rootView: EmptyStateView(viewModel: emptyStateViewModel)) + addChild(emptyStateViewHostingController) + emptyStateViewHostingController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(emptyStateViewHostingController.view) + NSLayoutConstraint.activate([ + emptyStateViewHostingController.view.topAnchor.constraint(equalTo: view.topAnchor), + emptyStateViewHostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + emptyStateViewHostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + emptyStateViewHostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + emptyStateViewHostingController.view.isHidden = true + emptyStateViewModel.$emptyState + .map { $0 == nil } + .receive(on: DispatchQueue.main) + .assign(to: \.isHidden, on: emptyStateViewHostingController.view) + .store(in: &disposeBag) + NotificationCenter.default .publisher(for: .statusBarTapped, object: nil) .throttle(for: 0.5, scheduler: DispatchQueue.main, latest: false) @@ -108,13 +153,6 @@ extension ListTimelineViewController { tableView.deselectRow(with: transitionCoordinator, animated: animated) } - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - // FIXME: use timer to auto refresh - autoFetchLatest() - } - override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) @@ -144,29 +182,9 @@ extension ListTimelineViewController { !diffableDataSource.snapshot().itemIdentifiers.isEmpty // conflict with LoadOldestState else { return } - if !viewModel.isLoadingLatest { - let now = Date() - if let timestamp = viewModel.lastAutomaticFetchTimestamp { - #if DEBUG - let throttle: TimeInterval = 1 - #else - let throttle: TimeInterval = 60 - #endif - if now.timeIntervalSince(timestamp) > throttle { - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Timeline] auto fetch lastest timeline…") - Task { - await _viewModel.loadLatest() - } - viewModel.lastAutomaticFetchTimestamp = now - } else { - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Timeline] auto fetch lastest timeline skip. Reason: updated in recent 60s") - } - } else { - Task { - await self.viewModel.loadLatest() - } - viewModel.lastAutomaticFetchTimestamp = now - } + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Timeline] auto fetch lastest timeline…") + Task { + await _viewModel.loadLatest() } } // end func @@ -256,7 +274,8 @@ extension ListTimelineViewController { // MARK: - UITableViewDelegate extension ListTimelineViewController: UITableViewDelegate, AutoGenerateTableViewDelegate { - // sourcery:inline:ListTimelineView + // sourcery:inline:ListTimelineViewController.AutoGenerateTableViewDelegate + // Generated using Sourcery // DO NOT EDIT func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { @@ -297,16 +316,25 @@ extension ListTimelineViewController: UITableViewDelegate, AutoGenerateTableView } } +// MARK: - UITableViewDataSourcePrefetching +extension ListTimelineViewController: UITableViewDataSourcePrefetching { + func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { + aspectTableView(tableView, prefetchRowsAt: indexPaths) + } +} + // MARK: - ScrollViewContainer extension ListTimelineViewController: ScrollViewContainer { var scrollView: UIScrollView { return tableView } - func scrollToTop(animated: Bool) { + func scrollToTop(animated: Bool, option: ScrollViewContainerOption) { if scrollView.contentOffset.y < scrollView.frame.height, !viewModel.isLoadingLatest, (scrollView.contentOffset.y + scrollView.adjustedContentInset.top) == 0.0, - !refreshControl.isRefreshing { + !refreshControl.isRefreshing, + option.tryRefreshWhenStayAtTop + { scrollView.scrollRectToVisible(CGRect(origin: CGPoint(x: 0, y: -refreshControl.frame.height), size: CGSize(width: 1, height: 1)), animated: animated) DispatchQueue.main.async { [weak self] in guard let self = self else { return } diff --git a/TwidereX/Scene/Timeline/Base/List/ListTimelineViewModel+Diffable.swift b/TwidereX/Scene/Timeline/Base/List/ListTimelineViewModel+Diffable.swift index d9bd3b53..f0d72b67 100644 --- a/TwidereX/Scene/Timeline/Base/List/ListTimelineViewModel+Diffable.swift +++ b/TwidereX/Scene/Timeline/Base/List/ListTimelineViewModel+Diffable.swift @@ -12,6 +12,10 @@ import Combine import CoreData import CoreDataStack +public protocol DifferenceItem { + var isTransient: Bool { get } +} + extension ListTimelineViewModel { struct Difference: CustomStringConvertible { @@ -30,12 +34,28 @@ extension ListTimelineViewModel { } } - @MainActor func calculateReloadSnapshotDifference( + @MainActor func calculateReloadSnapshotDifference( tableView: UITableView, oldSnapshot: NSDiffableDataSourceSnapshot, newSnapshot: NSDiffableDataSourceSnapshot ) -> Difference? { - guard let sourceIndexPath = (tableView.indexPathsForVisibleRows ?? []).sorted().first else { return nil } + guard oldSnapshot.numberOfItems != 0 else { return nil } + guard let indexPathsForVisibleRows = tableView.indexPathsForVisibleRows?.sorted() else { return nil } + + // find index of the first visible item in both old and new snapshot + var _index: Int? + let items = oldSnapshot.itemIdentifiers + for (i, item) in items.enumerated() { + guard let _ = indexPathsForVisibleRows.first(where: { $0.row == i }) else { continue } + guard !item.isTransient else { continue } + guard newSnapshot.indexOfItem(item) != nil else { continue } + _index = i + break + } + + guard let index = _index else { return nil } + let sourceIndexPath = IndexPath(row: index, section: 0) + let rectForSourceItemCell = tableView.rectForRow(at: sourceIndexPath) let sourceDistanceToTableViewTopEdge: CGFloat = { if tableView.window != nil { @@ -44,9 +64,6 @@ extension ListTimelineViewModel { return rectForSourceItemCell.origin.y - tableView.contentOffset.y - tableView.safeAreaInsets.top } }() - guard sourceIndexPath.section < oldSnapshot.numberOfSections, - sourceIndexPath.row < oldSnapshot.numberOfItems(inSection: oldSnapshot.sectionIdentifiers[sourceIndexPath.section]) - else { return nil } let sectionIdentifier = oldSnapshot.sectionIdentifiers[sourceIndexPath.section] let item = oldSnapshot.itemIdentifiers(inSection: sectionIdentifier)[sourceIndexPath.row] diff --git a/TwidereX/Scene/Timeline/Base/List/ListTimelineViewModel.swift b/TwidereX/Scene/Timeline/Base/List/ListTimelineViewModel.swift index e6485cb1..c97750e6 100644 --- a/TwidereX/Scene/Timeline/Base/List/ListTimelineViewModel.swift +++ b/TwidereX/Scene/Timeline/Base/List/ListTimelineViewModel.swift @@ -22,6 +22,10 @@ class ListTimelineViewModel: TimelineViewModel { animatingDifferences: Bool ) { diffableDataSource?.apply(snapshot, animatingDifferences: animatingDifferences) + + if enableAutoFetchLatest, !didAutoFetchLatest { + autoFetchLatestAction.send() + } } @MainActor @@ -29,6 +33,10 @@ class ListTimelineViewModel: TimelineViewModel { snapshot: NSDiffableDataSourceSnapshot ) { diffableDataSource?.applySnapshotUsingReloadData(snapshot) + + if enableAutoFetchLatest, !didAutoFetchLatest { + autoFetchLatestAction.send() + } } } @@ -37,16 +45,20 @@ extension ListTimelineViewModel { @MainActor func loadMore(item: StatusItem) async { - guard case let .feedLoader(record) = item else { return } - guard let authenticationContext = context.authenticationService.activeAuthenticationContext else { return } + guard case let .feedLoader(record) = item else { + assertionFailure() + return + } guard let diffableDataSource = diffableDataSource else { return } var snapshot = diffableDataSource.snapshot() + let authenticationContext = authContext.authenticationContext + let managedObjectContext = context.managedObjectContext let key = "LoadMore@\(record.objectID)#\(UUID().uuidString)" guard let feed = record.object(in: managedObjectContext) else { return } - guard let statusObject = feed.statusObject else { return } + guard case let .status(status) = feed.content else { return } // keep transient property alive managedObjectContext.cache(feed, key: key) @@ -72,7 +84,7 @@ extension ListTimelineViewModel { managedObjectContext: managedObjectContext, authenticationContext: authenticationContext, kind: kind, - position: .middle(anchor: statusObject.asRecord), + position: .middle(anchor: status.asRecord), filter: StatusFetchViewModel.Timeline.Filter(rule: .empty) ) let input = try await StatusFetchViewModel.Timeline.prepare(fetchContext: fetchContext) diff --git a/TwidereX/Scene/Timeline/Federated/FederatedTimelineViewModel+Diffable.swift b/TwidereX/Scene/Timeline/Federated/FederatedTimelineViewModel+Diffable.swift index bd52ee95..face7188 100644 --- a/TwidereX/Scene/Timeline/Federated/FederatedTimelineViewModel+Diffable.swift +++ b/TwidereX/Scene/Timeline/Federated/FederatedTimelineViewModel+Diffable.swift @@ -11,8 +11,6 @@ import UIKit import Combine import CoreData import CoreDataStack -import TwidereCore -import AppShared extension FederatedTimelineViewModel { @@ -24,15 +22,12 @@ extension FederatedTimelineViewModel { let configuration = StatusSection.Configuration( statusViewTableViewCellDelegate: statusViewTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: nil, - statusViewConfigurationContext: StatusView.ConfigurationContext( - dateTimeProvider: DateTimeSwiftProvider(), - twitterTextProvider: OfficialTwitterTextProvider(), - authenticationContext: context.authenticationService.$activeAuthenticationContext - ) + viewLayoutFramePublisher: $viewLayoutFrame ) diffableDataSource = StatusSection.diffableDataSource( tableView: tableView, context: context, + authContext: authContext, configuration: configuration ) diff --git a/TwidereX/Scene/Timeline/Federated/FederatedTimelineViewModel.swift b/TwidereX/Scene/Timeline/Federated/FederatedTimelineViewModel.swift index dc43f1a3..a833c8c8 100644 --- a/TwidereX/Scene/Timeline/Federated/FederatedTimelineViewModel.swift +++ b/TwidereX/Scene/Timeline/Federated/FederatedTimelineViewModel.swift @@ -14,34 +14,25 @@ final class FederatedTimelineViewModel: ListTimelineViewModel { init( context: AppContext, + authContext: AuthContext, isLocal: Bool ) { super.init( context: context, + authContext: authContext, kind: .public(isLocal: isLocal) ) enableAutoFetchLatest = true - - context.authenticationService.$activeAuthenticationContext - .sink { [weak self] authenticationContext in - guard let self = self else { return } - guard let authenticationContext = authenticationContext else { - self.statusRecordFetchedResultController.userIdentifier = nil - return - } - - switch authenticationContext { - case .twitter: - self.statusRecordFetchedResultController.userIdentifier = nil - case .mastodon(let authenticationContext): - self.statusRecordFetchedResultController.userIdentifier = .mastodon(.init( - domain: authenticationContext.domain, - id: authenticationContext.userID - )) - } - } - .store(in: &disposeBag) + switch authContext.authenticationContext { + case .twitter: + self.statusRecordFetchedResultController.userIdentifier = nil + case .mastodon(let authenticationContext): + self.statusRecordFetchedResultController.userIdentifier = .mastodon(.init( + domain: authenticationContext.domain, + id: authenticationContext.userID + )) + } } deinit { diff --git a/TwidereX/Scene/Timeline/Hashtag/HashtagTimelineViewModel+Diffable.swift b/TwidereX/Scene/Timeline/Hashtag/HashtagTimelineViewModel+Diffable.swift index 5c375573..432c8170 100644 --- a/TwidereX/Scene/Timeline/Hashtag/HashtagTimelineViewModel+Diffable.swift +++ b/TwidereX/Scene/Timeline/Hashtag/HashtagTimelineViewModel+Diffable.swift @@ -11,8 +11,6 @@ import UIKit import Combine import CoreData import CoreDataStack -import TwidereCore -import AppShared extension HashtagTimelineViewModel { @@ -24,15 +22,12 @@ extension HashtagTimelineViewModel { let configuration = StatusSection.Configuration( statusViewTableViewCellDelegate: statusViewTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: nil, - statusViewConfigurationContext: StatusView.ConfigurationContext( - dateTimeProvider: DateTimeSwiftProvider(), - twitterTextProvider: OfficialTwitterTextProvider(), - authenticationContext: context.authenticationService.$activeAuthenticationContext - ) + viewLayoutFramePublisher: $viewLayoutFrame ) diffableDataSource = StatusSection.diffableDataSource( tableView: tableView, context: context, + authContext: authContext, configuration: configuration ) diff --git a/TwidereX/Scene/Timeline/Hashtag/HashtagTimelineViewModel.swift b/TwidereX/Scene/Timeline/Hashtag/HashtagTimelineViewModel.swift index ea95d9b8..7abe1111 100644 --- a/TwidereX/Scene/Timeline/Hashtag/HashtagTimelineViewModel.swift +++ b/TwidereX/Scene/Timeline/Hashtag/HashtagTimelineViewModel.swift @@ -14,16 +14,16 @@ final class HashtagTimelineViewModel: ListTimelineViewModel { init( context: AppContext, + authContext: AuthContext, hashtag: String ) { super.init( context: context, + authContext: authContext, kind: .hashtag(hashtag: hashtag) ) - - context.authenticationService.$activeAuthenticationContext - .map { $0?.userIdentifier } - .assign(to: &statusRecordFetchedResultController.$userIdentifier) + + statusRecordFetchedResultController.userIdentifier = authContext.authenticationContext.userIdentifier } deinit { diff --git a/TwidereX/Scene/Timeline/Home/HomeTimelineViewController+DebugAction.swift b/TwidereX/Scene/Timeline/Home/HomeTimelineViewController+DebugAction.swift index d4a09fce..44917b6b 100644 --- a/TwidereX/Scene/Timeline/Home/HomeTimelineViewController+DebugAction.swift +++ b/TwidereX/Scene/Timeline/Home/HomeTimelineViewController+DebugAction.swift @@ -13,11 +13,8 @@ import UIKit import CoreData import CoreDataStack import TwitterSDK -import ZIPFoundation -import FLEX import MetaTextKit import MetaTextArea -import TwidereUI import SwiftMessages extension HomeTimelineViewController { @@ -100,10 +97,6 @@ extension HomeTimelineViewController { guard let self = self else { return } self.showAccountListAction(action) }), - UIAction(title: "Stub Timeline", image: nil, attributes: [], handler: { [weak self] action in - guard let self = self else { return } - self.showStubTimelineAction(action) - }), UIAction(title: "Local Timeline", image: nil, attributes: [], handler: { [weak self] action in guard let self = self else { return } self.showLocalTimelineAction(action) @@ -209,6 +202,10 @@ extension HomeTimelineViewController { guard let self = self else { return } self.moveToFirst(action, category: .blockingAuthor) }), + UIAction(title: "First Duplicated Status", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.moveToFirst(action, category: .duplicated) + }), ] ) } @@ -243,10 +240,6 @@ extension HomeTimelineViewController { identifier: nil, options: [], children: [ - UIAction(title: "Enable FLEX", image: nil, attributes: [], handler: { [weak self] action in - guard let self = self else { return } - self.showFLEXAction(action) - }), UIAction(title: "Display TextView Frame", image: nil, attributes: [], handler: { [weak self] action in guard let self = self else { return } self.displayTextViewFrame(action) @@ -266,40 +259,33 @@ extension HomeTimelineViewController { } extension HomeTimelineViewController { - - @objc private func showFLEXAction(_ sender: UIAction) { - FLEXManager.shared.showExplorer() - } - + @objc private func showStatusByID(_ id: String) { - Task { - let authenticationContext = self.context.authenticationService.activeAuthenticationContext + Task { @MainActor in + let authenticationContext = self.viewModel.authContext.authenticationContext switch authenticationContext { - case .twitter(let authenticationContext): - _ = try await self.context.apiService.twitterStatus( - statusIDs: [id], - authenticationContext: authenticationContext + case .twitter: + let statusThreadViewModel = StatusThreadViewModel( + context: self.context, + authContext: self.authContext, + kind: .twitter(id) ) - let request = TwitterStatus.sortedFetchRequest - request.predicate = TwitterStatus.predicate(id: id) - request.fetchLimit = 1 - let _status = try self.context.managedObjectContext.fetch(request).first - guard let status = _status else { - return - } + self.coordinator.present( + scene: .statusThread(viewModel: statusThreadViewModel), + from: self, + transition: .show + ) + case .mastodon(let authenticationContext): let statusThreadViewModel = StatusThreadViewModel( context: self.context, - root: .root(context: .init(status: .twitter(record: .init(objectID: status.objectID)))) + authContext: self.authContext, + kind: .mastodon(domain: authenticationContext.domain, id) ) - await self.coordinator.present( + self.coordinator.present( scene: .statusThread(viewModel: statusThreadViewModel), from: self, transition: .show ) - case .mastodon: - assertionFailure("TODO:") - default: - assertionFailure() } } // end Task } @@ -312,22 +298,18 @@ extension HomeTimelineViewController { ) } - @objc private func showStubTimelineAction(_ sender: UIAction) { - coordinator.present(scene: .stubTimeline, from: self, transition: .show) - } - @objc private func showLocalTimelineAction(_ sender: UIAction) { - let federatedTimelineViewModel = FederatedTimelineViewModel(context: context, isLocal: true) + let federatedTimelineViewModel = FederatedTimelineViewModel(context: context, authContext: authContext, isLocal: true) coordinator.present(scene: .federatedTimeline(viewModel: federatedTimelineViewModel), from: self, transition: .show) } @objc private func showPublicTimelineAction(_ sender: UIAction) { - let federatedTimelineViewModel = FederatedTimelineViewModel(context: context, isLocal: false) + let federatedTimelineViewModel = FederatedTimelineViewModel(context: context, authContext: authContext, isLocal: false) coordinator.present(scene: .federatedTimeline(viewModel: federatedTimelineViewModel), from: self, transition: .show) } @objc private func showAccountListAction(_ sender: UIAction) { - let accountListViewModel = AccountListViewModel(context: context) + let accountListViewModel = AccountListViewModel(context: context, authContext: authContext) coordinator.present(scene: .accountList(viewModel: accountListViewModel), from: self, transition: .modal(animated: true, completion: nil)) } @@ -341,9 +323,10 @@ extension HomeTimelineViewController { case followsYouAuthor case blockingAuthor case status(id: String) + case duplicated - func match(item: StatusItem) -> Bool { - let authenticationContext = AppContext.shared.authenticationService.activeAuthenticationContext + func match(item: StatusItem, authContext: AuthContext) -> Bool { + let authenticationContext = authContext.authenticationContext switch item { case .feed(let record): guard let feed = record.object(in: AppContext.shared.managedObjectContext) else { return false } @@ -352,13 +335,13 @@ extension HomeTimelineViewController { case .quote: return status.quote != nil case .gif: - return status.attachments.contains(where: { attachment in attachment.kind == .animatedGIF }) + return status.attachmentsTransient.contains(where: { attachment in attachment.kind == .animatedGIF }) case .video: - return status.attachments.contains(where: { attachment in attachment.kind == .video }) + return status.attachmentsTransient.contains(where: { attachment in attachment.kind == .video }) case .poll: return status.poll != nil case .location: - return status.location != nil + return status.locationTransient != nil case .followsYouAuthor: guard case let .twitter(authenticationContext) = authenticationContext else { return false } guard let me = authenticationContext.authenticationRecord.object(in: AppContext.shared.managedObjectContext)?.user else { return false } @@ -369,6 +352,8 @@ extension HomeTimelineViewController { return (status.repost ?? status).author.blockingBy.contains(me) case .status(let id): return status.id == id + case .duplicated: + return false default: return false } @@ -382,8 +367,50 @@ extension HomeTimelineViewController { } } - func firstMatch(in items: [StatusItem]) -> StatusItem? { - return items.first { item in self.match(item: item) } + func firstMatch(in items: [StatusItem], authContext: AuthContext) -> StatusItem? { + switch self { + case .duplicated: + var index = 0 + while index < items.count - 2 { + defer { index += 1 } + + let this = items[index] + let next = items[index + 1] + + switch (this, next) { + case (.feed(let thisRecord), .feed(let nextRecord)): + guard let thisFeed = thisRecord.object(in: AppContext.shared.managedObjectContext) else { continue } + guard let nextFeed = nextRecord.object(in: AppContext.shared.managedObjectContext) else { continue } + + if let thisTwitterStatus = thisFeed.twitterStatus, + let nextTwitterStatus = nextFeed.twitterStatus + { + if thisTwitterStatus.id == nextTwitterStatus.id { + return this + } else { + continue + } + } else if let thisMastodonStatus = thisFeed.mastodonStatus, + let nextMastodonStatus = nextFeed.mastodonStatus + { + if thisMastodonStatus.id == nextMastodonStatus.id { + return this + } else { + continue + } + } else { + continue + } + default: + continue + } + } + let logger = Logger(subsystem: "HomeTimelineViewController", category: "DebugAction") + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): not found duplicated in \(index) count items") + return nil + default: + return items.first { item in self.match(item: item, authContext: authContext) } + } } } @@ -391,7 +418,7 @@ extension HomeTimelineViewController { guard let diffableDataSource = viewModel.diffableDataSource else { return } let snapshot = diffableDataSource.snapshot() let items = snapshot.itemIdentifiers - guard let targetItem = category.firstMatch(in: items), + guard let targetItem = category.firstMatch(in: items, authContext: authContext), let index = snapshot.indexOfItem(targetItem) else { return } let indexPath = IndexPath(row: index, section: 0) @@ -534,14 +561,18 @@ extension HomeTimelineViewController { let droppingObjectIDs = snapshot.itemIdentifiers.prefix(count).compactMap { item -> [NSManagedObjectID]? in switch item { case .feed(let record): - var ids: [NSManagedObjectID] = [record.objectID] - if let feed = record.object(in: context.apiService.backgroundManagedObjectContext) { - if let objectID = feed.twitterStatus?.objectID { - ids.append(objectID) - } - if let objectID = feed.mastodonStatus?.objectID { - ids.append(objectID) + let managedObjectContext = context.managedObjectContext + let ids: [NSManagedObjectID] = managedObjectContext.performAndWait { + var ids: [NSManagedObjectID] = [record.objectID] + if let feed = record.object(in: managedObjectContext) { + if let objectID = feed.twitterStatus?.objectID { + ids.append(objectID) + } + if let objectID = feed.mastodonStatus?.objectID { + ids.append(objectID) + } } + return ids } return ids default: diff --git a/TwidereX/Scene/Timeline/Home/HomeTimelineViewController.swift b/TwidereX/Scene/Timeline/Home/HomeTimelineViewController.swift index 52e0749d..af7d1dfe 100644 --- a/TwidereX/Scene/Timeline/Home/HomeTimelineViewController.swift +++ b/TwidereX/Scene/Timeline/Home/HomeTimelineViewController.swift @@ -9,14 +9,14 @@ import os.log import UIKit import Combine -import TwidereUI import TwidereLocalization final class HomeTimelineViewController: ListTimelineViewController { static var unreadIndicatorViewTopMargin: CGFloat { 16 } let unreadIndicatorView = UnreadIndicatorView() - + var unreadIndicatorViewTrailingLayoutConstraint: NSLayoutConstraint! + // ref: https://medium.com/@Mos6yCanSwift/swift-ios-determine-scroll-direction-d48a2327a004 var lastVelocityYSign = 0 var lastContentOffset: CGPoint? @@ -37,12 +37,24 @@ extension HomeTimelineViewController { // setup unreadIndicatorView unreadIndicatorView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(unreadIndicatorView) + unreadIndicatorViewTrailingLayoutConstraint = view.layoutMarginsGuide.trailingAnchor.constraint(equalTo: unreadIndicatorView.trailingAnchor) NSLayoutConstraint.activate([ unreadIndicatorView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: 16), - view.layoutMarginsGuide.trailingAnchor.constraint(equalTo: unreadIndicatorView.trailingAnchor), + unreadIndicatorViewTrailingLayoutConstraint, unreadIndicatorView.widthAnchor.constraint(greaterThanOrEqualToConstant: 36).priority(.required - 1), unreadIndicatorView.heightAnchor.constraint(greaterThanOrEqualToConstant: 36).priority(.required - 1), ]) + viewModel.$viewLayoutFrame + .receive(on: DispatchQueue.main) + .sink { [weak self] viewLayoutFrame in + guard let self = self else { return } + if viewLayoutFrame.layoutFrame.width == viewLayoutFrame.readableContentLayoutFrame.width { + self.unreadIndicatorViewTrailingLayoutConstraint.constant = 16 + } else { + self.unreadIndicatorViewTrailingLayoutConstraint.constant = 0 + } + } + .store(in: &disposeBag) unreadIndicatorView.alpha = 0 viewModel.didLoadLatest .receive(on: DispatchQueue.main) @@ -111,6 +123,22 @@ extension HomeTimelineViewController { super.viewDidAppear(animated) unreadIndicatorView.startDisplayLink() + +// #if DEBUG +// DispatchQueue.once { +// guard let authenticationContext = self.context.authenticationService.activeAuthenticationContext else { return } +// let historyViewModel = HistoryViewModel( +// context: self.context, +// coordinator: coordinator, +// authContext: .init(authenticationContext: authenticationContext) +// ) +// self.coordinator.present( +// scene: .history(viewModel: historyViewModel), +// from: self, +// transition: .show +// ) +// } +// #endif } override func viewDidDisappear(_ animated: Bool) { @@ -261,6 +289,8 @@ extension HomeTimelineViewController { } guard indexPath.row < oldIndexPath.row else { + // always update the number + viewModel.unreadItemCount = oldIndexPath.row return } diff --git a/TwidereX/Scene/Timeline/Home/HomeTimelineViewModel+Diffable.swift b/TwidereX/Scene/Timeline/Home/HomeTimelineViewModel+Diffable.swift index e350ef22..84caa1e8 100644 --- a/TwidereX/Scene/Timeline/Home/HomeTimelineViewModel+Diffable.swift +++ b/TwidereX/Scene/Timeline/Home/HomeTimelineViewModel+Diffable.swift @@ -11,7 +11,6 @@ import UIKit import Combine import CoreData import CoreDataStack -import AppShared extension HomeTimelineViewModel { @@ -24,15 +23,12 @@ extension HomeTimelineViewModel { let configuration = StatusSection.Configuration( statusViewTableViewCellDelegate: statusViewTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate, - statusViewConfigurationContext: .init( - dateTimeProvider: DateTimeSwiftProvider(), - twitterTextProvider: OfficialTwitterTextProvider(), - authenticationContext: context.authenticationService.$activeAuthenticationContext - ) + viewLayoutFramePublisher: $viewLayoutFrame ) diffableDataSource = StatusSection.diffableDataSource( tableView: tableView, context: context, + authContext: authContext, configuration: configuration ) diff --git a/TwidereX/Scene/Timeline/Home/HomeTimelineViewModel.swift b/TwidereX/Scene/Timeline/Home/HomeTimelineViewModel.swift index 77b47622..4f4601eb 100644 --- a/TwidereX/Scene/Timeline/Home/HomeTimelineViewModel.swift +++ b/TwidereX/Scene/Timeline/Home/HomeTimelineViewModel.swift @@ -20,33 +20,33 @@ final class HomeTimelineViewModel: ListTimelineViewModel { @Published var unreadItemCount = 0 @Published var loadItemCount = 0 - init(context: AppContext) { - super.init(context: context, kind: .home) - + init( + context: AppContext, + authContext: AuthContext + ) { + super.init( + context: context, + authContext: authContext, + kind: .home + ) + // end init + enableAutoFetchLatest = true - context.authenticationService.$activeAuthenticationContext - .sink { [weak self] authenticationContext in - guard let self = self else { return } - let emptyFeedPredicate = Feed.nonePredicate() - guard let authenticationContext = authenticationContext else { - self.feedFetchedResultsController.predicate = emptyFeedPredicate - return - } - - let predicate: NSPredicate - switch authenticationContext { - case .twitter(let authenticationContext): - let userID = authenticationContext.userID - predicate = Feed.predicate(kind: .home, acct: Feed.Acct.twitter(userID: userID)) - case .mastodon(let authenticationContext): - let domain = authenticationContext.domain - let userID = authenticationContext.userID - predicate = Feed.predicate(kind: .home, acct: Feed.Acct.mastodon(domain: domain, userID: userID)) - } - self.feedFetchedResultsController.predicate = predicate + feedFetchedResultsController.predicate = { + let predicate: NSPredicate + let authenticationContext = authContext.authenticationContext + switch authenticationContext { + case .twitter(let authenticationContext): + let userID = authenticationContext.userID + predicate = Feed.predicate(kind: .home, acct: Feed.Acct.twitter(userID: userID)) + case .mastodon(let authenticationContext): + let domain = authenticationContext.domain + let userID = authenticationContext.userID + predicate = Feed.predicate(kind: .home, acct: Feed.Acct.mastodon(domain: domain, userID: userID)) } - .store(in: &disposeBag) + return predicate + }() } deinit { @@ -60,9 +60,10 @@ extension HomeTimelineViewModel { var sinceID: String? { guard let first = feedFetchedResultsController.records.first, - let object = first.object(in: context.managedObjectContext)?.statusObject + let feed = first.object(in: context.managedObjectContext), + case let .status(status) = feed.content else { return nil } - return object.id + return status.id } } diff --git a/TwidereX/Scene/Timeline/List/ListStatusTimelineViewController.swift b/TwidereX/Scene/Timeline/List/ListStatusTimelineViewController.swift index 260fbc62..1921db6c 100644 --- a/TwidereX/Scene/Timeline/List/ListStatusTimelineViewController.swift +++ b/TwidereX/Scene/Timeline/List/ListStatusTimelineViewController.swift @@ -49,30 +49,19 @@ extension ListStatusTimelineViewController { } navigationItem.rightBarButtonItem = menuBarButtonItem - context.authenticationService.$activeAuthenticationContext - .asyncMap { [weak self] authenticationContext -> UIMenu? in - guard let self = self else { return nil } - guard case let .list(list) = self.viewModel.kind else { return nil } - guard let authenticationContext = authenticationContext else { return nil } - do { - let menu = try await DataSourceFacade.createMenuForList( - dependency: self, - list: list, - authenticationContext: authenticationContext - ) - return menu - } catch { - assertionFailure(error.localizedDescription) - return nil - } - } - .receive(on: DispatchQueue.main) - .sink { [weak self] menu in - guard let self = self else { return } - guard let menu = menu else { return } + Task { + guard case let .list(list) = self.viewModel.kind else { return } + do { + let menu = try await DataSourceFacade.createMenuForList( + dependency: self, + list: list, + authenticationContext: self.viewModel.authContext.authenticationContext + ) self.menuBarButtonItem.menu = menu + } catch { + assertionFailure(error.localizedDescription) } - .store(in: &disposeBag) + } // end Task viewModel.$isDeleted .receive(on: DispatchQueue.main) diff --git a/TwidereX/Scene/Timeline/List/ListStatusTimelineViewModel+Diffable.swift b/TwidereX/Scene/Timeline/List/ListStatusTimelineViewModel+Diffable.swift index 6af0c953..ade26ac1 100644 --- a/TwidereX/Scene/Timeline/List/ListStatusTimelineViewModel+Diffable.swift +++ b/TwidereX/Scene/Timeline/List/ListStatusTimelineViewModel+Diffable.swift @@ -7,8 +7,6 @@ // import UIKit -import TwidereUI -import AppShared extension ListStatusTimelineViewModel { @@ -20,15 +18,12 @@ extension ListStatusTimelineViewModel { let configuration = StatusSection.Configuration( statusViewTableViewCellDelegate: statusViewTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: nil, - statusViewConfigurationContext: StatusView.ConfigurationContext( - dateTimeProvider: DateTimeSwiftProvider(), - twitterTextProvider: OfficialTwitterTextProvider(), - authenticationContext: context.authenticationService.$activeAuthenticationContext - ) + viewLayoutFramePublisher: $viewLayoutFrame ) diffableDataSource = StatusSection.diffableDataSource( tableView: tableView, context: context, + authContext: authContext, configuration: configuration ) diff --git a/TwidereX/Scene/Timeline/List/ListStatusTimelineViewModel.swift b/TwidereX/Scene/Timeline/List/ListStatusTimelineViewModel.swift index 164340b0..473ba47c 100644 --- a/TwidereX/Scene/Timeline/List/ListStatusTimelineViewModel.swift +++ b/TwidereX/Scene/Timeline/List/ListStatusTimelineViewModel.swift @@ -13,6 +13,9 @@ import CoreDataStack import TwidereCore final class ListStatusTimelineViewModel: ListTimelineViewModel { + + // input + let list: ListRecord // output @Published var title: String? @@ -20,18 +23,19 @@ final class ListStatusTimelineViewModel: ListTimelineViewModel { init( context: AppContext, + authContext: AuthContext, list: ListRecord ) { + self.list = list super.init( context: context, + authContext: authContext, kind: .list(list: list) ) + // end init - isFloatyButtonDisplay = false - - context.authenticationService.$activeAuthenticationContext - .map { $0?.userIdentifier } - .assign(to: &statusRecordFetchedResultController.$userIdentifier) + isFloatyButtonDisplay = true + statusRecordFetchedResultController.userIdentifier = authContext.authenticationContext.userIdentifier // bind titile if let object = list.object(in: context.managedObjectContext) { diff --git a/TwidereX/Scene/Timeline/Search/Media/SearchMediaTimelineViewModel+Diffable.swift b/TwidereX/Scene/Timeline/Search/Media/SearchMediaTimelineViewModel+Diffable.swift index 1027cc54..14620772 100644 --- a/TwidereX/Scene/Timeline/Search/Media/SearchMediaTimelineViewModel+Diffable.swift +++ b/TwidereX/Scene/Timeline/Search/Media/SearchMediaTimelineViewModel+Diffable.swift @@ -10,6 +10,7 @@ import os.log import UIKit import CoreData import CoreDataStack +import TwidereCore extension SearchMediaTimelineViewModel { @MainActor func setupDiffableDataSource( diff --git a/TwidereX/Scene/Timeline/Search/Media/SearchMediaTimelineViewModel.swift b/TwidereX/Scene/Timeline/Search/Media/SearchMediaTimelineViewModel.swift index e9414c50..4293d082 100644 --- a/TwidereX/Scene/Timeline/Search/Media/SearchMediaTimelineViewModel.swift +++ b/TwidereX/Scene/Timeline/Search/Media/SearchMediaTimelineViewModel.swift @@ -18,7 +18,8 @@ final class SearchMediaTimelineViewModel: GridTimelineViewModel { // output init( - context: AppContext + context: AppContext, + authContext: AuthContext ) { let searchTimelineContext = StatusFetchViewModel.Timeline.Kind.SearchTimelineContext( timelineKind: .media, @@ -26,15 +27,14 @@ final class SearchMediaTimelineViewModel: GridTimelineViewModel { ) super.init( context: context, + authContext: authContext, kind: .search(searchTimelineContext: searchTimelineContext) ) isRefreshControlEnabled = false isFloatyButtonDisplay = false - context.authenticationService.$activeAuthenticationContext - .map { $0?.userIdentifier } - .assign(to: &statusRecordFetchedResultController.$userIdentifier) + statusRecordFetchedResultController.userIdentifier = authContext.authenticationContext.userIdentifier // bind searchText $searchText.assign(to: &searchTimelineContext.$searchText) diff --git a/TwidereX/Scene/Timeline/Search/Status/SearchTimelineViewModel+Diffable.swift b/TwidereX/Scene/Timeline/Search/Status/SearchTimelineViewModel+Diffable.swift index b89aa145..855cb060 100644 --- a/TwidereX/Scene/Timeline/Search/Status/SearchTimelineViewModel+Diffable.swift +++ b/TwidereX/Scene/Timeline/Search/Status/SearchTimelineViewModel+Diffable.swift @@ -7,8 +7,6 @@ // import UIKit -import TwidereUI -import AppShared extension SearchTimelineViewModel { @@ -20,15 +18,12 @@ extension SearchTimelineViewModel { let configuration = StatusSection.Configuration( statusViewTableViewCellDelegate: statusViewTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: nil, - statusViewConfigurationContext: StatusView.ConfigurationContext( - dateTimeProvider: DateTimeSwiftProvider(), - twitterTextProvider: OfficialTwitterTextProvider(), - authenticationContext: context.authenticationService.$activeAuthenticationContext - ) + viewLayoutFramePublisher: $viewLayoutFrame ) diffableDataSource = StatusSection.diffableDataSource( tableView: tableView, context: context, + authContext: authContext, configuration: configuration ) diff --git a/TwidereX/Scene/Timeline/Search/Status/SearchTimelineViewModel.swift b/TwidereX/Scene/Timeline/Search/Status/SearchTimelineViewModel.swift index ed400487..108d75d2 100644 --- a/TwidereX/Scene/Timeline/Search/Status/SearchTimelineViewModel.swift +++ b/TwidereX/Scene/Timeline/Search/Status/SearchTimelineViewModel.swift @@ -14,11 +14,12 @@ final class SearchTimelineViewModel: ListTimelineViewModel { // input @Published var searchText = "" - + // output init( - context: AppContext + context: AppContext, + authContext: AuthContext ) { let searchTimelineContext = StatusFetchViewModel.Timeline.Kind.SearchTimelineContext( timelineKind: .status, @@ -26,15 +27,14 @@ final class SearchTimelineViewModel: ListTimelineViewModel { ) super.init( context: context, + authContext: authContext, kind: .search(searchTimelineContext: searchTimelineContext) ) isRefreshControlEnabled = false isFloatyButtonDisplay = false - context.authenticationService.$activeAuthenticationContext - .map { $0?.userIdentifier } - .assign(to: &statusRecordFetchedResultController.$userIdentifier) + statusRecordFetchedResultController.userIdentifier = authContext.authenticationContext.userIdentifier // bind searchText $searchText.assign(to: &searchTimelineContext.$searchText) diff --git a/TwidereX/Scene/Timeline/User/Like/MeLikeTimelineViewModel.swift b/TwidereX/Scene/Timeline/User/Like/MeLikeTimelineViewModel.swift deleted file mode 100644 index 55acb5f4..00000000 --- a/TwidereX/Scene/Timeline/User/Like/MeLikeTimelineViewModel.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// MeLikeTimelineViewModel.swift -// TwidereX -// -// Created by MainasuK on 2022-6-15. -// Copyright © 2022 Twidere. All rights reserved. -// - -import UIKit -import TwidereCore - -final class MeLikeTimelineViewModel: UserLikeTimelineViewModel { - - init(context: AppContext) { - let timelineContext = StatusFetchViewModel.Timeline.Kind.UserTimelineContext( - timelineKind: .like, - userIdentifier: nil - ) - super.init( - context: context, - timelineContext: timelineContext - ) - - context.authenticationService.$activeAuthenticationContext - .map { $0?.userIdentifier } - .assign(to: &timelineContext.$userIdentifier) - } - -} diff --git a/TwidereX/Scene/Timeline/User/Media/UserMediaTimelineViewModel.swift b/TwidereX/Scene/Timeline/User/Media/UserMediaTimelineViewModel.swift index a57ba32b..ee707d20 100644 --- a/TwidereX/Scene/Timeline/User/Media/UserMediaTimelineViewModel.swift +++ b/TwidereX/Scene/Timeline/User/Media/UserMediaTimelineViewModel.swift @@ -12,8 +12,16 @@ import TwidereCore final class UserMediaTimelineViewModel: GridTimelineViewModel { - init(context: AppContext, timelineContext: StatusFetchViewModel.Timeline.Kind.UserTimelineContext) { - super.init(context: context, kind: .user(userTimelineContext: timelineContext)) + init( + context: AppContext, + authContext: AuthContext, + timelineContext: StatusFetchViewModel.Timeline.Kind.UserTimelineContext + ) { + super.init( + context: context, + authContext: authContext, + kind: .user(userTimelineContext: timelineContext) + ) timelineContext.$userIdentifier .assign(to: &statusRecordFetchedResultController.$userIdentifier) @@ -22,5 +30,5 @@ final class UserMediaTimelineViewModel: GridTimelineViewModel { deinit { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) } - + } diff --git a/TwidereX/Scene/Timeline/User/Status/UserTimelineViewModel+Diffable.swift b/TwidereX/Scene/Timeline/User/Status/UserTimelineViewModel+Diffable.swift index 3e3f39b0..6bf2a6b5 100644 --- a/TwidereX/Scene/Timeline/User/Status/UserTimelineViewModel+Diffable.swift +++ b/TwidereX/Scene/Timeline/User/Status/UserTimelineViewModel+Diffable.swift @@ -11,8 +11,6 @@ import UIKit import Combine import CoreData import CoreDataStack -import TwidereCore -import AppShared extension UserTimelineViewModel { @@ -24,15 +22,12 @@ extension UserTimelineViewModel { let configuration = StatusSection.Configuration( statusViewTableViewCellDelegate: statusViewTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: nil, - statusViewConfigurationContext: StatusView.ConfigurationContext( - dateTimeProvider: DateTimeSwiftProvider(), - twitterTextProvider: OfficialTwitterTextProvider(), - authenticationContext: context.authenticationService.$activeAuthenticationContext - ) + viewLayoutFramePublisher: $viewLayoutFrame ) diffableDataSource = StatusSection.diffableDataSource( tableView: tableView, context: context, + authContext: authContext, configuration: configuration ) @@ -83,7 +78,7 @@ extension UserTimelineViewModel { self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): snapshot has changes") } - await self.updateDataSource(snapshot: newSnapshot, animatingDifferences: false) + self.updateDataSource(snapshot: newSnapshot, animatingDifferences: false) self.didLoadLatest.send() self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): applied new snapshot") diff --git a/TwidereX/Scene/Timeline/User/Status/UserTimelineViewModel.swift b/TwidereX/Scene/Timeline/User/Status/UserTimelineViewModel.swift index acc73ee4..ae31bc75 100644 --- a/TwidereX/Scene/Timeline/User/Status/UserTimelineViewModel.swift +++ b/TwidereX/Scene/Timeline/User/Status/UserTimelineViewModel.swift @@ -14,8 +14,16 @@ class UserTimelineViewModel: ListTimelineViewModel { @Published var userIdentifier: UserIdentifier? - init(context: AppContext, timelineContext: StatusFetchViewModel.Timeline.Kind.UserTimelineContext) { - super.init(context: context, kind: .user(userTimelineContext: timelineContext)) + init( + context: AppContext, + authContext: AuthContext, + timelineContext: StatusFetchViewModel.Timeline.Kind.UserTimelineContext + ) { + super.init( + context: context, + authContext: authContext, + kind: .user(userTimelineContext: timelineContext) + ) timelineContext.$userIdentifier .assign(to: &$userIdentifier) diff --git a/TwidereX/Scene/Transition/DrawerSidebar/DrawerSidebarAnimatedTransitioning.swift b/TwidereX/Scene/Transition/DrawerSidebar/DrawerSidebarAnimatedTransitioning.swift index 9d4ad1f8..99f099d1 100644 --- a/TwidereX/Scene/Transition/DrawerSidebar/DrawerSidebarAnimatedTransitioning.swift +++ b/TwidereX/Scene/Transition/DrawerSidebar/DrawerSidebarAnimatedTransitioning.swift @@ -8,7 +8,6 @@ import UIKit import CommonOSLog -import TwidereUI final class DrawerSidebarAnimatedTransitioning: ViewControllerAnimatedTransitioning { diff --git a/TwidereX/Scene/Transition/DrawerSidebar/DrawerSidebarTransitionController.swift b/TwidereX/Scene/Transition/DrawerSidebar/DrawerSidebarTransitionController.swift index c27541a7..f5dd5020 100644 --- a/TwidereX/Scene/Transition/DrawerSidebar/DrawerSidebarTransitionController.swift +++ b/TwidereX/Scene/Transition/DrawerSidebar/DrawerSidebarTransitionController.swift @@ -9,7 +9,7 @@ import os.log import UIKit -protocol DrawerSidebarTransitionHostViewController: UIViewController & NeedsDependency { +protocol DrawerSidebarTransitionHostViewController: UIViewController & NeedsDependency & AuthContextProvider { var drawerSidebarTransitionController: DrawerSidebarTransitionController! { get } var avatarBarButtonItem: AvatarBarButtonItem { get } } @@ -145,10 +145,14 @@ extension DrawerSidebarTransitionController { switch transitionType { case .present: wantsInteractive = true + let drawerSidebarViewModel = DrawerSidebarViewModel( + context: hostViewController.context, + authContext: hostViewController.authContext + ) hostViewController.coordinator.present( - scene: .drawerSidebar(viewModel: DrawerSidebarViewModel(context: hostViewController.context)), + scene: .drawerSidebar(viewModel: drawerSidebarViewModel), from: hostViewController, - transition: .custom(transitioningDelegate: self) + transition: .custom(animated: true, transitioningDelegate: self) ) case .dismiss: diff --git a/TwidereX/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift b/TwidereX/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift index ddc40e45..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,43 +244,8 @@ 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 } } @@ -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 337755cd..3d4cfc18 100644 --- a/TwidereX/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift +++ b/TwidereX/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift @@ -7,7 +7,6 @@ // import UIKit -import TwidereUI class MediaPreviewTransitionItem: Identifiable { @@ -18,8 +17,7 @@ class MediaPreviewTransitionItem: Identifiable { // source var image: UIImage? var aspectRatio: CGSize? - var initialFrame: CGRect? = nil - var sourceImageView: UIImageView? + var initialFrame: CGRect? = nil // start frame for .push animation var sourceImageViewCornerRadius: CGFloat? // target @@ -48,8 +46,7 @@ class MediaPreviewTransitionItem: Identifiable { extension MediaPreviewTransitionItem { enum Source { case none - case attachment(MediaView) - case attachments(MediaGridContainerView) + case mediaView(MediaView.ViewModel, viewModels: [MediaView.ViewModel]) case profileAvatar(ProfileHeaderView) case profileBanner(ProfileHeaderView) @@ -57,23 +54,29 @@ extension MediaPreviewTransitionItem { position: UIViewAnimatingPosition, index: Int? ) { - let alpha: CGFloat = position == .end ? 1 : 0 switch self { case .none: break - case .attachment(let mediaView): - mediaView.alpha = alpha - case .attachments(let mediaGridContainerView): - if let index = index { - mediaGridContainerView.setAlpha(0, index: index) + 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 { - mediaGridContainerView.setAlpha(alpha) + viewModel.shouldHideForTransitioning = shouldHideForTransitioning } - case .profileAvatar(let profileHeaderView): - profileHeaderView.avatarView.avatarButton.alpha = alpha - case .profileBanner: - break // keep source + case .profileAvatar(let view): + // TODO: + break + case .profileBanner(let view): + // TODO: + break } +// case .profileAvatar(let profileHeaderView): +// profileHeaderView.avatarView.avatarButton.alpha = alpha +// case .profileBanner: +// break // keep source +// } } } } diff --git a/TwidereX/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift b/TwidereX/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift index 3689ffb0..a3d0b914 100644 --- a/TwidereX/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift +++ b/TwidereX/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift @@ -24,12 +24,13 @@ extension MediaPreviewableViewController { height: 44 ) return frame - case .attachment(let mediaView): - return mediaView.superview?.convert(mediaView.frame, to: nil) - case .attachments(let mediaGridContainerView): - guard index < mediaGridContainerView.mediaViews.count else { return nil } - let mediaView = mediaGridContainerView.mediaViews[index] - return mediaView.superview?.convert(mediaView.frame, to: nil) + 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/AppDelegate.swift b/TwidereX/Supporting Files/AppDelegate.swift index 8ef813a9..22aa3428 100644 --- a/TwidereX/Supporting Files/AppDelegate.swift +++ b/TwidereX/Supporting Files/AppDelegate.swift @@ -12,9 +12,8 @@ import Combine import Floaty import Firebase import FirebaseMessaging +import FirebaseCrashlytics import Kingfisher -import AppShared -import TwidereCommon @_exported import TwidereUI @@ -39,6 +38,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { FirebaseApp.configure() Crashlytics.crashlytics().setCustomValue(Locale.preferredLanguages.first ?? "nil", forKey: "preferredLanguage") Messaging.messaging().delegate = self + #if DEBUG + Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(false) + #endif // configure AudioSession try? AVAudioSession.sharedInstance().setCategory(.ambient) diff --git a/TwidereX/Supporting Files/SceneDelegate.swift b/TwidereX/Supporting Files/SceneDelegate.swift index 410817dd..e39aa675 100644 --- a/TwidereX/Supporting Files/SceneDelegate.swift +++ b/TwidereX/Supporting Files/SceneDelegate.swift @@ -11,8 +11,7 @@ import Combine import Intents import FPSIndicator import CoreDataStack -import TwidereCore -import AppShared +import CoreData class SceneDelegate: UIResponder, UIWindowSceneDelegate { @@ -23,7 +22,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? var coordinator: SceneCoordinator? - #if PROFILE + #if DEBUG || PROFILE var fpsIndicator: FPSIndicator? #endif @@ -33,7 +32,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { let window = UIWindow(windowScene: windowScene) self.window = window - #if DEBUG + #if DEBUG && !PROFILE guard !SceneDelegate.isXcodeUnitTest else { window.rootViewController = UIViewController() return @@ -60,12 +59,19 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { let sceneCoordinator = SceneCoordinator(scene: scene, sceneDelegate: self, context: AppContext.shared) self.coordinator = sceneCoordinator - sceneCoordinator.setup() - sceneCoordinator.setupWelcomeIfNeeds() + 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() - #if PROFILE + #if DEBUG || PROFILE fpsIndicator = FPSIndicator(windowScene: windowScene) #endif } @@ -153,22 +159,16 @@ 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) let composeContentViewModel = ComposeContentViewModel( - 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 - ) - ) + context: coordinator.context, + authContext: authContext, + kind: .post ) coordinator.present( scene: .compose( @@ -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/TwidereX/TwidereX-Performance.xctestplan b/TwidereX/TestPlan/TwidereX-Performance.xctestplan similarity index 88% rename from TwidereX/TwidereX-Performance.xctestplan rename to TwidereX/TestPlan/TwidereX-Performance.xctestplan index 553ffcde..0f267004 100644 --- a/TwidereX/TwidereX-Performance.xctestplan +++ b/TwidereX/TestPlan/TwidereX-Performance.xctestplan @@ -16,8 +16,8 @@ }, "testTargets" : [ { - "selectedTests" : [ - "TwidereXUITests\/testLaunchPerformance()" + "skippedTests" : [ + "TwidereXUITests" ], "target" : { "containerPath" : "container:TwidereX.xcodeproj", diff --git a/TwidereX/TwidereX.xctestplan b/TwidereX/TestPlan/TwidereX.xctestplan similarity index 86% rename from TwidereX/TwidereX.xctestplan rename to TwidereX/TestPlan/TwidereX.xctestplan index c31985d2..e2ee199a 100644 --- a/TwidereX/TwidereX.xctestplan +++ b/TwidereX/TestPlan/TwidereX.xctestplan @@ -25,10 +25,7 @@ } }, { - "selectedTests" : [ - "TwidereXUITests\/testExample()", - "TwidereXUITests\/testLaunchPerformance()" - ], + "enabled" : false, "target" : { "containerPath" : "container:TwidereX.xcodeproj", "identifier" : "DBDA8E3E24FCF8A7006750DC", diff --git a/TwidereXIntent/Handler/PublishPostIntentHandler.swift b/TwidereXIntent/Handler/PublishPostIntentHandler.swift index d75e6e01..8c732458 100644 --- a/TwidereXIntent/Handler/PublishPostIntentHandler.swift +++ b/TwidereXIntent/Handler/PublishPostIntentHandler.swift @@ -13,7 +13,6 @@ import CoreData import CoreDataStack import TwidereCore import TwidereCommon -import AppShared final class PublishPostIntentHandler: NSObject { diff --git a/TwidereXIntent/Handler/SwitchAccountIntentHandler.swift b/TwidereXIntent/Handler/SwitchAccountIntentHandler.swift index 39cea4b5..7e292bb2 100644 --- a/TwidereXIntent/Handler/SwitchAccountIntentHandler.swift +++ b/TwidereXIntent/Handler/SwitchAccountIntentHandler.swift @@ -12,7 +12,6 @@ import Intents import CoreData import CoreDataStack import TwidereCore -import TwidereCommon final class SwitchAccountIntentHandler: NSObject { diff --git a/TwidereXIntent/Info.plist b/TwidereXIntent/Info.plist index 74bdbc2e..585aebc6 100644 --- a/TwidereXIntent/Info.plist +++ b/TwidereXIntent/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.4.2 + 2.0.1 CFBundleVersion - 117 + 140 NSExtension NSExtensionAttributes diff --git a/TwidereXIntent/ar.lproj/Intents.strings b/TwidereXIntent/ar.lproj/Intents.strings index f3ebaae0..f6b8247a 100644 --- a/TwidereXIntent/ar.lproj/Intents.strings +++ b/TwidereXIntent/ar.lproj/Intents.strings @@ -24,7 +24,7 @@ "Fn8AQn" = "الرابط"; -"G5v3xr" = "Set active account"; +"G5v3xr" = "تعيين حساب نشط"; "GILN5g" = "هُناك ${count} خِيار مُطابق لِـ\"${accounts}\"."; diff --git a/TwidereXIntent/ca.lproj/Intents.strings b/TwidereXIntent/ca.lproj/Intents.strings index 28fc3329..29012bae 100644 --- a/TwidereXIntent/ca.lproj/Intents.strings +++ b/TwidereXIntent/ca.lproj/Intents.strings @@ -10,7 +10,7 @@ "1iPE2d-jEAHra" = "Just to confirm, you wanted ‘Unlisted’?"; -"8WWS78" = "Username"; +"8WWS78" = "Nom d'usuari"; "9OV1Ic" = "Posts"; diff --git a/TwidereXIntent/es.lproj/Intents.strings b/TwidereXIntent/es.lproj/Intents.strings index d1d20064..2ef31ab1 100644 --- a/TwidereXIntent/es.lproj/Intents.strings +++ b/TwidereXIntent/es.lproj/Intents.strings @@ -56,17 +56,17 @@ "aeaw8w" = "Directo"; -"ch7ADX" = "Compose new post"; +"ch7ADX" = "Crear una nueva publicación"; "f1YNIs" = "Publicar contenido"; "jEAHra" = "No listado"; -"kWl9zU" = "Post ${content}with ${accounts}"; +"kWl9zU" = "Publicar ${content} con ${accounts}"; "noeHVX" = "Publicación"; -"vbgnbQ" = "Post ${content}with ${accounts}"; +"vbgnbQ" = "Publicar ${content} con ${accounts}"; "xeqcBa-BS59z4" = "Hay ${count} opciones que coinciden con \"Público\"."; diff --git a/TwidereXIntent/ja.lproj/Intents.strings b/TwidereXIntent/ja.lproj/Intents.strings index 7918fbf6..f682aa6a 100644 --- a/TwidereXIntent/ja.lproj/Intents.strings +++ b/TwidereXIntent/ja.lproj/Intents.strings @@ -1,83 +1,83 @@ -"0e8JfP" = "Just to confirm, you wanted ‘${account}’?"; +"0e8JfP" = "「${account}」で間違いないですか?"; -"1iPE2d-BS59z4" = "Just to confirm, you wanted ‘Public’?"; +"1iPE2d-BS59z4" = "「パブリック」で間違いないですか?"; -"1iPE2d-aV5Ezl" = "Just to confirm, you wanted ‘Default’?"; +"1iPE2d-aV5Ezl" = "「デフォルト」で間違いないですか?"; -"1iPE2d-aV8f0g" = "Just to confirm, you wanted ‘Private’?"; +"1iPE2d-aV8f0g" = "「プライベート」で間違いないですか?"; -"1iPE2d-aeaw8w" = "Just to confirm, you wanted ‘Direct’?"; +"1iPE2d-aeaw8w" = "「ダイレクト」で間違いないですか?"; -"1iPE2d-jEAHra" = "Just to confirm, you wanted ‘Unlisted’?"; +"1iPE2d-jEAHra" = "「リストから削除」で間違いないですか?"; "8WWS78" = "ユーザー名"; -"9OV1Ic" = "Posts"; +"9OV1Ic" = "投稿"; -"ApmQKm" = "Switch Account"; +"ApmQKm" = "アカウントの切り替え"; "BS59z4" = "公開"; "Ciuk7c" = "名前"; -"DBgjXT" = "Account"; +"DBgjXT" = "アカウント"; "Fn8AQn" = "URL"; -"G5v3xr" = "Set active account"; +"G5v3xr" = "アクティブなアカウントを設定"; -"GILN5g" = "There are ${count} options matching ‘${accounts}’."; +"GILN5g" = "${accounts} にマッチする${count} 個のオプションがあります。"; "N4GVJI" = "${errorDescription}"; -"QSA5ql" = "Account"; +"QSA5ql" = "アカウント"; -"QWcTQP" = "Switch to ${account}"; +"QWcTQP" = "${account}に変更"; -"SnCJhk" = "Publish Post"; +"SnCJhk" = "投稿する"; -"SqRrlA" = "Just to confirm, you wanted ‘${accounts}’?"; +"SqRrlA" = "「${accounts}」で間違いないですか?"; -"SrV2FP" = "Error Description"; +"SrV2FP" = "エラーの説明"; -"TzDamL" = "Toot Visibility"; +"TzDamL" = "公開範囲"; "WVP0I1" = "${errorDescription}"; -"Xs6a35" = "Toot Visibility"; +"Xs6a35" = "公開範囲"; -"YNUo2I" = "There are ${count} options matching ‘${account}’."; +"YNUo2I" = "${account} にマッチする${count} 個のオプションがあります。"; "Yb4tzx" = "アカウント"; -"aV5Ezl" = "Default"; +"aV5Ezl" = "デフォルト"; "aV8f0g" = "フォロワー限定"; "aeaw8w" = "ダイレクト"; -"ch7ADX" = "Compose new post"; +"ch7ADX" = "投稿の新規作成"; -"f1YNIs" = "Post Content"; +"f1YNIs" = "投稿内容"; "jEAHra" = "未収載"; -"kWl9zU" = "Post ${content}with ${accounts}"; +"kWl9zU" = "${accounts}で${content}を投稿"; -"noeHVX" = "Post"; +"noeHVX" = "投稿"; -"vbgnbQ" = "Post ${content}with ${accounts}"; +"vbgnbQ" = "${accounts}で${content}を投稿"; -"xeqcBa-BS59z4" = "There are ${count} options matching ‘Public’."; +"xeqcBa-BS59z4" = "「パブリック」にマッチするオプションが${count}個あります。"; -"xeqcBa-aV5Ezl" = "There are ${count} options matching ‘Default’."; +"xeqcBa-aV5Ezl" = " 「デフォルト」にマッチする${count} 個のオプションがあります。"; -"xeqcBa-aV8f0g" = "There are ${count} options matching ‘Private’."; +"xeqcBa-aV8f0g" = "「プライベート」にマッチするオプションが${count}個あります。"; -"xeqcBa-aeaw8w" = "There are ${count} options matching ‘Direct’."; +"xeqcBa-aeaw8w" = "「ダイレクト」にマッチするオプションが${count}個あります。"; -"xeqcBa-jEAHra" = "There are ${count} options matching ‘Unlisted’."; +"xeqcBa-jEAHra" = "「リストから削除」にマッチするオプションが${count}個あります。"; -"z3DAP7" = "Switch to ${account}"; +"z3DAP7" = "${account}に変更"; -"zlMGvn" = "Content"; +"zlMGvn" = "コンテンツ"; diff --git a/TwidereXIntent/tr.lproj/Intents.strings b/TwidereXIntent/tr.lproj/Intents.strings index 3c51c9c2..36846d61 100644 --- a/TwidereXIntent/tr.lproj/Intents.strings +++ b/TwidereXIntent/tr.lproj/Intents.strings @@ -1,14 +1,14 @@ -"0e8JfP" = "Just to confirm, you wanted ‘${account}’?"; +"0e8JfP" = "Onaylamak için, \"${account}\" mu istediniz?"; -"1iPE2d-BS59z4" = "Just to confirm, you wanted ‘Public’?"; +"1iPE2d-BS59z4" = "Sadece onaylamak için \"Halka açık\" olarak mı istediniz?"; -"1iPE2d-aV5Ezl" = "Just to confirm, you wanted ‘Default’?"; +"1iPE2d-aV5Ezl" = "Sadece onaylamak için \"Varsayılan\" olarak mı istediniz?"; -"1iPE2d-aV8f0g" = "Just to confirm, you wanted ‘Private’?"; +"1iPE2d-aV8f0g" = "Sadece onaylamak için \"Özel\" olarak mı istediniz?"; -"1iPE2d-aeaw8w" = "Just to confirm, you wanted ‘Direct’?"; +"1iPE2d-aeaw8w" = "Sadece onaylamak için \"Direkt\" olarak mı istediniz?"; -"1iPE2d-jEAHra" = "Just to confirm, you wanted ‘Unlisted’?"; +"1iPE2d-jEAHra" = "Sadece onaylamak için \"Listelenmemiş\" olarak mı istediniz?"; "8WWS78" = "Kullanıcı adı"; @@ -26,7 +26,7 @@ "G5v3xr" = "Hesabı etkinleştir"; -"GILN5g" = "There are ${count} options matching ‘${accounts}’."; +"GILN5g" = "\"${accounts}\" ile eşleşen ${count} seçenek var."; "N4GVJI" = "${errorDescription}"; @@ -36,7 +36,7 @@ "SnCJhk" = "Gönderiyi Yayınla"; -"SqRrlA" = "Just to confirm, you wanted ‘${accounts}’?"; +"SqRrlA" = "Sadece onaylamak için, \"${accounts}\" mu istediniz?"; "SrV2FP" = "Hata Açıklaması"; @@ -46,7 +46,7 @@ "Xs6a35" = "Toot Görünürlüğü"; -"YNUo2I" = "There are ${count} options matching ‘${account}’."; +"YNUo2I" = "\"${account}\" ile eşleşen ${count} seçenek var."; "Yb4tzx" = "Hesaplar"; @@ -56,27 +56,27 @@ "aeaw8w" = "Direkt"; -"ch7ADX" = "Compose new post"; +"ch7ADX" = "Yeni bir gönderi oluştur"; "f1YNIs" = "Gönderi İçeriği"; "jEAHra" = "Liste dışı"; -"kWl9zU" = "Post ${content}with ${accounts}"; +"kWl9zU" = "${content} ${accounts} ile yayınla"; "noeHVX" = "Gönderi"; -"vbgnbQ" = "Post ${content}with ${accounts}"; +"vbgnbQ" = "${content} ${accounts} ile yayınla"; -"xeqcBa-BS59z4" = "There are ${count} options matching ‘Public’."; +"xeqcBa-BS59z4" = "\"Herkese açık\" olarak eşleşen ${count} seçenek var."; -"xeqcBa-aV5Ezl" = "There are ${count} options matching ‘Default’."; +"xeqcBa-aV5Ezl" = "\"Varsayılan\" olarak eşleşen ${count} seçenek var."; -"xeqcBa-aV8f0g" = "There are ${count} options matching ‘Private’."; +"xeqcBa-aV8f0g" = "\"Özel\" olarak eşleşen ${count} seçenek var."; -"xeqcBa-aeaw8w" = "There are ${count} options matching ‘Direct’."; +"xeqcBa-aeaw8w" = "\"Direkt\" olarak eşleşen ${count} seçenek var."; -"xeqcBa-jEAHra" = "There are ${count} options matching ‘Unlisted’."; +"xeqcBa-jEAHra" = "\"Liste dışı\" olarak eşleşen ${count} seçenek var."; "z3DAP7" = "${account} hesabına geç"; diff --git a/TwidereXTests/CombineTests.swift b/TwidereXTests/CombineTests.swift index f3e99499..0c863535 100644 --- a/TwidereXTests/CombineTests.swift +++ b/TwidereXTests/CombineTests.swift @@ -9,7 +9,8 @@ import os.log import XCTest import Combine -@testable import TwidereX +import TwidereCore +import TwidereCommon class CombineTests: XCTestCase { @@ -22,67 +23,67 @@ class CombineTests: XCTestCase { /// /// This test case crash on the iOS 14.1 /// EXC_BAD_INSTRUCTION - func testMapRetrySwitchToLatest() throws { - let outputExpectation = self.expectation(description: "future") - - let inputA = PassthroughSubject() - let inputB = PassthroughSubject() - - Publishers.CombineLatest( - inputA.eraseToAnyPublisher(), - inputB.eraseToAnyPublisher() - ) - .compactMap { inputA, inputB -> (Int, Int)? in - guard let inputA = inputA, let inputB = inputB else { return nil } - return (inputA, inputB) - } - .setFailureType(to: Error.self) - .map { inputA, inputB -> AnyPublisher in - return Future { promise in - AppContext.shared.backgroundManagedObjectContext.perform { - promise(.success(inputA + inputB)) - } - } - .tryMap { output -> AnyPublisher in - guard output != 0 else { - throw StubError.stub - } - - return AppContext.shared.backgroundManagedObjectContext.performChanges { - // do nothing - } - .setFailureType(to: Error.self) - .tryMap { result -> Int in - switch result { - case .success: return output - case .failure(let error): throw error - } - } - .eraseToAnyPublisher() - } - .switchToLatest() - .eraseToAnyPublisher() - .retry(3) - .eraseToAnyPublisher() - } - .switchToLatest() - .sink { completion in - switch completion { - case .failure(let error): - outputExpectation.fulfill() - case .finished: - break - } - } receiveValue: { response in - self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(response)") - } - .store(in: &disposeBag) - - inputA.send(1) - inputB.send(-1) - - wait(for: [outputExpectation], timeout: 20) - } +// func testMapRetrySwitchToLatest() throws { +// let outputExpectation = self.expectation(description: "future") +// +// let inputA = PassthroughSubject() +// let inputB = PassthroughSubject() +// +// Publishers.CombineLatest( +// inputA.eraseToAnyPublisher(), +// inputB.eraseToAnyPublisher() +// ) +// .compactMap { inputA, inputB -> (Int, Int)? in +// guard let inputA = inputA, let inputB = inputB else { return nil } +// return (inputA, inputB) +// } +// .setFailureType(to: Error.self) +// .map { inputA, inputB -> AnyPublisher in +// return Future { promise in +// AppContext.shared.backgroundManagedObjectContext.perform { +// promise(.success(inputA + inputB)) +// } +// } +// .tryMap { output -> AnyPublisher in +// guard output != 0 else { +// throw StubError.stub +// } +// +// return AppContext.shared.backgroundManagedObjectContext.performChanges { +// // do nothing +// } +// .setFailureType(to: Error.self) +// .tryMap { result -> Int in +// switch result { +// case .success: return output +// case .failure(let error): throw error +// } +// } +// .eraseToAnyPublisher() +// } +// .switchToLatest() +// .eraseToAnyPublisher() +// .retry(3) +// .eraseToAnyPublisher() +// } +// .switchToLatest() +// .sink { completion in +// switch completion { +// case .failure(let error): +// outputExpectation.fulfill() +// case .finished: +// break +// } +// } receiveValue: { response in +// self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(response)") +// } +// .store(in: &disposeBag) +// +// inputA.send(1) +// inputB.send(-1) +// +// wait(for: [outputExpectation], timeout: 20) +// } } diff --git a/TwidereXTests/Info.plist b/TwidereXTests/Info.plist index 2ee4462a..af6bca1c 100644 --- a/TwidereXTests/Info.plist +++ b/TwidereXTests/Info.plist @@ -15,8 +15,8 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.4.2 + 2.0.1 CFBundleVersion - 117 + 140 diff --git a/TwidereXTests/TwidereXTests+Issue92.swift b/TwidereXTests/TwidereXTests+Issue92.swift index e89bdc20..96636c7b 100644 --- a/TwidereXTests/TwidereXTests+Issue92.swift +++ b/TwidereXTests/TwidereXTests+Issue92.swift @@ -7,7 +7,7 @@ // import XCTest -@testable import TwidereX +import TwidereCore // https://github.com/TwidereProject/TwidereX-iOS/issues/92 extension TwidereXTests { diff --git a/TwidereXTests/TwidereXTests.swift b/TwidereXTests/TwidereXTests.swift index 36a35584..e1f65d67 100644 --- a/TwidereXTests/TwidereXTests.swift +++ b/TwidereXTests/TwidereXTests.swift @@ -7,7 +7,6 @@ import os.log import XCTest -@testable import TwidereX class TwidereXTests: XCTestCase { diff --git a/TwidereXUITests/Info.plist b/TwidereXUITests/Info.plist index 2ee4462a..af6bca1c 100644 --- a/TwidereXUITests/Info.plist +++ b/TwidereXUITests/Info.plist @@ -15,8 +15,8 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.4.2 + 2.0.1 CFBundleVersion - 117 + 140 diff --git a/TwidereXUITests/TwidereXUITests+Onboarding.swift b/TwidereXUITests/TwidereXUITests+Onboarding.swift index 94d236d7..d5c0776a 100644 --- a/TwidereXUITests/TwidereXUITests+Onboarding.swift +++ b/TwidereXUITests/TwidereXUITests+Onboarding.swift @@ -21,6 +21,7 @@ extension TwidereXUITests { try await authorizeTwitter(app: app) } + } extension TwidereXUITests { diff --git a/TwidereXUITests/TwidereXUITests+Performance.swift b/TwidereXUITests/TwidereXUITests+Performance.swift new file mode 100644 index 00000000..cb01199a --- /dev/null +++ b/TwidereXUITests/TwidereXUITests+Performance.swift @@ -0,0 +1,44 @@ +// +// TwidereXUITests+Performance.swift +// TwidereXUITests +// +// Created by MainasuK on 2023/3/28. +// Copyright © 2023 Twidere. All rights reserved. +// + +import XCTest + +final class TwidereXUITests_Performance: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + +} + +extension TwidereXUITests_Performance { + + func testHomeTimelineScrollingAnimationPerformance() throws { + let app = XCUIApplication() + app.launch() + + let tableView = app.tables.firstMatch + XCTAssert(tableView.waitForExistence(timeout: 5)) + + let measureOptions = XCTMeasureOptions() + measureOptions.invocationOptions = [.manuallyStop] + + measure(metrics: [XCTOSSignpostMetric.scrollingAndDecelerationMetric], options: measureOptions) { + tableView.swipeUp(velocity: .fast) + tableView.swipeUp(velocity: .fast) + stopMeasuring() + tableView.swipeDown(velocity: .fast) + tableView.swipeDown(velocity: .fast) + } + } + +}