From 332dca6b47991f5ccbd7dda659b5d61b04947719 Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Mon, 10 Jan 2022 15:09:41 +0000 Subject: [PATCH 01/36] Start work on homemade Twitter API client --- Broadcast.xcodeproj/project.pbxproj | 12 ++-- .../UserInterfaceState.xcuserstate | Bin 0 -> 28068 bytes Broadcast/BroadcastApp.swift | 3 + Broadcast/TestAuthenticationProvider.swift | 60 ++++++++++++++++++ 4 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 Broadcast.xcodeproj/project.xcworkspace/xcuserdata/dte.xcuserdatad/UserInterfaceState.xcuserstate create mode 100644 Broadcast/TestAuthenticationProvider.swift diff --git a/Broadcast.xcodeproj/project.pbxproj b/Broadcast.xcodeproj/project.pbxproj index 8f7380f..872a70f 100644 --- a/Broadcast.xcodeproj/project.pbxproj +++ b/Broadcast.xcodeproj/project.pbxproj @@ -48,6 +48,7 @@ 7199AE8026B9892E001DEB46 /* Debouncer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7199AE7F26B9892E001DEB46 /* Debouncer.swift */; }; 71A1088A268B073B007E1FFB /* Haptics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71A10889268B073B007E1FFB /* Haptics.swift */; }; 71A6A264278C73AD00BF2387 /* TwitterAPI-Info.example.plist in Resources */ = {isa = PBXBuildFile; fileRef = 71A6A263278C73AD00BF2387 /* TwitterAPI-Info.example.plist */; }; + 71A6A266278C784C00BF2387 /* TestAuthenticationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71A6A265278C784C00BF2387 /* TestAuthenticationProvider.swift */; }; 71AA4AC6268A032400B7B577 /* RemoteImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71AA4AC5268A032400B7B577 /* RemoteImage.swift */; }; 71B8290C268D0AC6002AEE72 /* TwitterClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71B8290B268D0AC6002AEE72 /* TwitterClient.swift */; }; 71B82910268F2195002AEE72 /* LastTweetReplyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71B8290F268F2195002AEE72 /* LastTweetReplyView.swift */; }; @@ -108,6 +109,7 @@ 7199AE7F26B9892E001DEB46 /* Debouncer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debouncer.swift; sourceTree = ""; }; 71A10889268B073B007E1FFB /* Haptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Haptics.swift; sourceTree = ""; }; 71A6A263278C73AD00BF2387 /* TwitterAPI-Info.example.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "TwitterAPI-Info.example.plist"; sourceTree = ""; }; + 71A6A265278C784C00BF2387 /* TestAuthenticationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAuthenticationProvider.swift; sourceTree = ""; }; 71AA4AC5268A032400B7B577 /* RemoteImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImage.swift; sourceTree = ""; }; 71B8290B268D0AC6002AEE72 /* TwitterClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwitterClient.swift; sourceTree = ""; }; 71B8290F268F2195002AEE72 /* LastTweetReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastTweetReplyView.swift; sourceTree = ""; }; @@ -181,6 +183,7 @@ 71BBAAEA268CF532004048A0 /* TwitterAPI-Info.plist */, 71A6A263278C73AD00BF2387 /* TwitterAPI-Info.example.plist */, 71800BFB26999B1B009D11A1 /* DraftsModel.xcdatamodeld */, + 71A6A265278C784C00BF2387 /* TestAuthenticationProvider.swift */, ); path = Broadcast; sourceTree = ""; @@ -395,6 +398,7 @@ 7188E63B2687B19D007CFD78 /* Notification.extension.swift in Sources */, 717041F326B6FFEA00001360 /* RepliesListView.swift in Sources */, 7188E6292687B0FE007CFD78 /* BroadcastApp.swift in Sources */, + 71A6A266278C784C00BF2387 /* TestAuthenticationProvider.swift in Sources */, 71B8290C268D0AC6002AEE72 /* TwitterClient.swift in Sources */, 7188E6632688A0FC007CFD78 /* WelcomeView.swift in Sources */, 7199AE7A26B96D0D001DEB46 /* NSRegularExpression+Convenience.swift in Sources */, @@ -530,7 +534,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.5; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -585,7 +589,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.5; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; @@ -606,7 +610,7 @@ DEVELOPMENT_TEAM = YC249PY26F; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = Broadcast/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -630,7 +634,7 @@ DEVELOPMENT_TEAM = YC249PY26F; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = Broadcast/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Broadcast.xcodeproj/project.xcworkspace/xcuserdata/dte.xcuserdatad/UserInterfaceState.xcuserstate b/Broadcast.xcodeproj/project.xcworkspace/xcuserdata/dte.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000000000000000000000000000000000000..8f86d0c506e079ef1efa8024a576f4879a731ec2 GIT binary patch literal 28068 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 J&OaUN{~r&SNRt2n literal 0 HcmV?d00001 diff --git a/Broadcast/BroadcastApp.swift b/Broadcast/BroadcastApp.swift index 2be99a3..7f8dc65 100644 --- a/Broadcast/BroadcastApp.swift +++ b/Broadcast/BroadcastApp.swift @@ -30,6 +30,9 @@ struct BroadcastApp: App { persistenceController.save() } + .task { + await AuthenticationProvider().requestAuthentication() + } VisualEffectView(effect: UIBlurEffect(style: .regular)) .frame(height: geom.safeAreaInsets.top) diff --git a/Broadcast/TestAuthenticationProvider.swift b/Broadcast/TestAuthenticationProvider.swift new file mode 100644 index 0000000..ae4867f --- /dev/null +++ b/Broadcast/TestAuthenticationProvider.swift @@ -0,0 +1,60 @@ +// +// TestAuthenticationProvider.swift +// Broadcast +// +// Created by Daniel Eden on 10/01/2022. +// + +import Foundation +import AuthenticationServices +import Combine + +class AuthenticationProvider: NSObject, ObservableObject, ASWebAuthenticationPresentationContextProviding { + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + return ASPresentationAnchor() + } + + func requestAuthentication() async { + let creds = TwitterClient.ClientCredentials.self + + guard let callbackURL = creds.callbackURL.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlFragmentAllowed) else { + return + } + + var requestTokenComponents = URLComponents() + + requestTokenComponents.scheme = "https" + requestTokenComponents.host = "api.twitter.com" + requestTokenComponents.path = "/2/oauth/request_token" + requestTokenComponents.queryItems = [ + URLQueryItem(name: "oauth_callback", value: callbackURL) + ] + + guard let requestTokenURL = requestTokenComponents.url else { + return + } + + var requestTokenRequest = URLRequest(url: requestTokenURL) + + requestTokenRequest.httpMethod = "POST" + + let clientKey = creds.apiKey + let clientSecret = creds.apiSecret + let requestTokenAuthorizationHeader = """ +OAuth +oauth_consumer_key="\(clientKey)" +""" + + requestTokenRequest.setValue("", forHTTPHeaderField: "Authorization") + + do { + let (requestTokenData, _) = try await URLSession.shared.data(for: requestTokenRequest) + + let decoded = try JSONDecoder().decode(String.self, from: requestTokenData) + + print(decoded) + } catch { + print(error.localizedDescription) + } + } +} From 4fea287e21fdb789051fb19accddfde41b94883b Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Mon, 10 Jan 2022 16:53:23 +0000 Subject: [PATCH 02/36] Chipping away --- Broadcast.xcodeproj/project.pbxproj | 4 + .../UserInterfaceState.xcuserstate | Bin 28068 -> 31324 bytes Broadcast/Extensions/URLRequest+OAuth.swift | 318 ++++++++++++++++++ Broadcast/TestAuthenticationProvider.swift | 82 +++-- 4 files changed, 374 insertions(+), 30 deletions(-) create mode 100644 Broadcast/Extensions/URLRequest+OAuth.swift diff --git a/Broadcast.xcodeproj/project.pbxproj b/Broadcast.xcodeproj/project.pbxproj index 872a70f..dc996b0 100644 --- a/Broadcast.xcodeproj/project.pbxproj +++ b/Broadcast.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 7101073626C810AC00A713A5 /* NullStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7101073526C810AC00A713A5 /* NullStateView.swift */; }; 711EF99426C959A700FD8A9F /* BroadcastUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711EF99326C959A700FD8A9F /* BroadcastUITests.swift */; }; 711F3FFA268F50C800605C89 /* Animation.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711F3FF9268F50C800605C89 /* Animation.extension.swift */; }; + 712EF6E0278C8B60007C09F6 /* URLRequest+OAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 712EF6DF278C8B60007C09F6 /* URLRequest+OAuth.swift */; }; 715AAE0A26C923A1002BCEA1 /* Swifter in Frameworks */ = {isa = PBXBuildFile; productRef = 715AAE0926C923A1002BCEA1 /* Swifter */; }; 715AAE0C26C9589A002BCEA1 /* TestUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 715AAE0B26C9589A002BCEA1 /* TestUtils.swift */; }; 717041F326B6FFEA00001360 /* RepliesListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 717041F226B6FFEA00001360 /* RepliesListView.swift */; }; @@ -73,6 +74,7 @@ 711EF99326C959A700FD8A9F /* BroadcastUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastUITests.swift; sourceTree = ""; }; 711EF99526C959A700FD8A9F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 711F3FF9268F50C800605C89 /* Animation.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Animation.extension.swift; sourceTree = ""; }; + 712EF6DF278C8B60007C09F6 /* URLRequest+OAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLRequest+OAuth.swift"; sourceTree = ""; }; 715AAE0B26C9589A002BCEA1 /* TestUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestUtils.swift; sourceTree = ""; }; 717041F226B6FFEA00001360 /* RepliesListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepliesListView.swift; sourceTree = ""; }; 717041F426B7037300001360 /* TweetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TweetView.swift; sourceTree = ""; }; @@ -208,6 +210,7 @@ 71800BF7269998BF009D11A1 /* UIImage.extension.swift */, 717041F626B703A600001360 /* TwitterClient+MockTweet.swift */, 7199AE7926B96D0D001DEB46 /* NSRegularExpression+Convenience.swift */, + 712EF6DF278C8B60007C09F6 /* URLRequest+OAuth.swift */, ); path = Extensions; sourceTree = ""; @@ -393,6 +396,7 @@ buildActionMask = 2147483647; files = ( 7188E6612688A01F007CFD78 /* Font.extension.swift in Sources */, + 712EF6E0278C8B60007C09F6 /* URLRequest+OAuth.swift in Sources */, 7188E62B2687B0FE007CFD78 /* ContentView.swift in Sources */, 717041F526B7037300001360 /* TweetView.swift in Sources */, 7188E63B2687B19D007CFD78 /* Notification.extension.swift in Sources */, diff --git a/Broadcast.xcodeproj/project.xcworkspace/xcuserdata/dte.xcuserdatad/UserInterfaceState.xcuserstate b/Broadcast.xcodeproj/project.xcworkspace/xcuserdata/dte.xcuserdatad/UserInterfaceState.xcuserstate index 8f86d0c506e079ef1efa8024a576f4879a731ec2..198d4a9b9e1e390d3b76f9ad23577f3b5d22a927 100644 GIT binary patch delta 17662 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 OVgmo~%0GT;#{EB?EV!Ql delta 15279 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
  • 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 diff --git a/Broadcast/Extensions/URLRequest+OAuth.swift b/Broadcast/Extensions/URLRequest+OAuth.swift new file mode 100644 index 0000000..68dbe8e --- /dev/null +++ b/Broadcast/Extensions/URLRequest+OAuth.swift @@ -0,0 +1,318 @@ +// +// URLRequest+OAuth.swift +// Broadcast +// +// Created by Daniel Eden on 10/01/2022. +// + +// +// Apache License, Version 2.0 +// +// Copyright 2017, Markus Wanke +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +/// # OhhAuth +/// ## Pure Swift implementation of the OAuth 1.0 protocol as an easy to use extension for the URLRequest type. +/// - Author: Markus Wanke +/// - Copyright: 2017 + +import Foundation + +public class OhhAuth +{ + /// Tuple to represent signing credentials. (consumer as well as user credentials) + public typealias Credentials = (key: String, secret: String) + + + /// Function to calculate the OAuth protocol parameters and signature ready to be added + /// as the HTTP header "Authorization" entry. A detailed explanation of the procedure + /// can be found at: [RFC-5849 Section 3](https://tools.ietf.org/html/rfc5849#section-3) + /// + /// - Parameters: + /// - url: Request url (with all query parameters etc.) + /// - method: HTTP method + /// - parameter: url-form parameters + /// - consumerCredentials: consumer credentials + /// - userCredentials: user credentials (nil if this is a request without user association) + /// + /// - Returns: OAuth HTTP header entry for the Authorization field. + static func calculateSignature(url: URL, method: String, parameter: [String: String], + consumerCredentials cc: Credentials, userCredentials uc: Credentials?) -> String + { + typealias Tup = (key: String, value: String) + + let tuplify: (String, String) -> Tup = { + return (key: rfc3986encode($0), value: rfc3986encode($1)) + } + let cmp: (Tup, Tup) -> Bool = { + return $0.key < $1.key + } + let toPairString: (Tup) -> String = { + return $0.key + "=" + $0.value + } + let toBrackyPairString: (Tup) -> String = { + return $0.key + "=\"" + $0.value + "\"" + } + + /// [RFC-5849 Section 3.1](https://tools.ietf.org/html/rfc5849#section-3.1) + var oAuthParameters = oAuthDefaultParameters(consumerKey: cc.key, userKey: uc?.key) + + /// [RFC-5849 Section 3.4.1.3.1](https://tools.ietf.org/html/rfc5849#section-3.4.1.3.1) + let signString: String = [oAuthParameters, parameter, url.queryParameters()] + .flatMap { $0.map(tuplify) } + .sorted(by: cmp) + .map(toPairString) + .joined(separator: "&") + + + /// [RFC-5849 Section 3.4.1](https://tools.ietf.org/html/rfc5849#section-3.4.1) + let signatureBase: String = [method, url.oAuthBaseURL(), signString] + .map(rfc3986encode) + .joined(separator: "&") + + /// [RFC-5849 Section 3.4.2](https://tools.ietf.org/html/rfc5849#section-3.4.2) + let signingKey: String = [cc.secret, uc?.secret ?? ""] + .map(rfc3986encode) + .joined(separator: "&") + + /// [RFC-5849 Section 3.4.2](https://tools.ietf.org/html/rfc5849#section-3.4.2) + let binarySignature = HMAC.calculate(withHash: .sha1, key: signingKey, message: signatureBase) + oAuthParameters["oauth_signature"] = binarySignature.base64EncodedString() + + /// [RFC-5849 Section 3.5.1](https://tools.ietf.org/html/rfc5849#section-3.5.1) + return "OAuth " + oAuthParameters + .map(tuplify) + .sorted(by: cmp) + .map(toBrackyPairString) + .joined(separator: ",") + } + + + + /// Function to perform the right percentage encoding for url form parameters. + /// + /// - Parameter paras: url-form parameters + /// - Parameter encoding: used string encoding (default: .utf8) + /// - Returns: correctly percentage encoded url-form parameters + static func httpBody(forFormParameters paras: [String: String], encoding: String.Encoding = .utf8) -> Data? + { + let trans: (String, String) -> String = { k, v in + return rfc3986encode(k) + "=" + rfc3986encode(v) + } + + return paras.map(trans).joined(separator: "&").data(using: encoding) + } + + /// OAuth cites RFC-3986 for percentage encoding. + /// Characters that don't need to be converted are: ALPHA, DIGIT, "-", ".", "_", "~" + /// [RFC-5849 Section 3.6](https://tools.ietf.org/html/rfc5849#section-3.6) + /// [RFC-3986 Section 2.3](https://tools.ietf.org/html/rfc3986#section-2.3) + /// Predefined CharacterSets are not used to be 100% RFC conform and + /// avoid possible unicode conversion problems. + private static func rfc3986encode(_ str: String) -> String + { + struct Static { + static let allowed = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-._~" + static let allowedSet = CharacterSet(charactersIn: allowed) + } + return str.addingPercentEncoding(withAllowedCharacters: Static.allowedSet) ?? str + } + + private static func oAuthDefaultParameters(consumerKey: String, userKey: String?) -> [String: String] + { + /// [RFC-5849 Section 3.1](https://tools.ietf.org/html/rfc5849#section-3.1) + var defaults: [String: String] = [ + "oauth_consumer_key": consumerKey, + "oauth_signature_method": "HMAC-SHA1", + "oauth_version": "1.0", + /// [RFC-5849 Section 3.3](https://tools.ietf.org/html/rfc5849#section-3.3) + "oauth_timestamp": String(Int(Date().timeIntervalSince1970)), + "oauth_nonce": UUID().uuidString, + ] + if let userKey = userKey { + defaults["oauth_token"] = userKey + } + return defaults + } +} + + +public extension URLRequest +{ + /// Easy to use method to sign a URLRequest which includes url-form parameters with OAuth. + /// The request needs a valid URL with all query parameters etc. included. + /// After calling this method the HTTP header fields: "Authorization", "Content-Type" + /// and "Content-Length" should not be overwritten. + /// + /// - Parameters: + /// - method: HTTP Method + /// - paras: url-form parameters + /// - consumerCredentials: consumer credentials + /// - userCredentials: user credentials (nil if this is a request without user association) + mutating func oAuthSign(method: String, urlFormParameters paras: [String: String], + consumerCredentials cc: OhhAuth.Credentials, userCredentials uc: OhhAuth.Credentials? = nil) + { + self.httpMethod = method.uppercased() + + let body = OhhAuth.httpBody(forFormParameters: paras) + + self.httpBody = body + self.addValue(String(body?.count ?? 0), forHTTPHeaderField: "Content-Length") + self.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + + let sig = OhhAuth.calculateSignature(url: self.url!, method: self.httpMethod!, + parameter: paras, consumerCredentials: cc, userCredentials: uc) + + self.addValue(sig, forHTTPHeaderField: "Authorization") + } + + /// Easy to use method to sign a URLRequest which includes plain body data with OAuth. + /// The request needs a valid URL with all query parameters etc. included. + /// After calling this method the HTTP header fields: "Authorization", "Content-Type" + /// and "Content-Length" should not be overwritten. + /// + /// - Parameters: + /// - method: HTTP Method + /// - body: HTTP request body (default: nil) + /// - contentType: HTTP header "Content-Type" entry (default: nil) + /// - consumerCredentials: consumer credentials + /// - userCredentials: user credentials (nil if this is a request without user association) + mutating func oAuthSign(method: String, body: Data? = nil, contentType: String? = nil, + consumerCredentials cc: OhhAuth.Credentials, userCredentials uc: OhhAuth.Credentials? = nil) + { + self.httpMethod = method.uppercased() + + if let body = body { + self.httpBody = body + self.addValue(String(body.count), forHTTPHeaderField: "Content-Length") + } + + if let ct = contentType { + self.addValue(ct, forHTTPHeaderField: "Content-Type") + } + + let sig = OhhAuth.calculateSignature(url: self.url!, method: self.httpMethod!, + parameter: [:], consumerCredentials: cc, userCredentials: uc) + + self.addValue(sig, forHTTPHeaderField: "Authorization") + } +} + + + +/// Hash-based message authentication helper class. +fileprivate class HMAC +{ + enum HashMethod: UInt32 + { + /// See + case sha1, md5, sha256, sha384, sha512, sha224 + + var length: Int { + switch self { + case .md5: return 16 + case .sha1: return 20 + case .sha224: return 28 + case .sha256: return 32 + case .sha384: return 48 + case .sha512: return 64 + } + } + } + + + /// Function to calculate a hash-based message authentication code (aka HMAC) + /// + /// - Parameters: + /// - withHash: hash function used (one of: .sha1, .md5, .sha256, .sha384, .sha512, .sha224) + /// - key: the key + /// - message: the message + /// - Returns: the HMAC + static func calculate(withHash hash: HashMethod, key: String, message msg: String) -> Data + { + let mac = UnsafeMutablePointer.allocate(capacity: hash.length) + let keyLen = CUnsignedLong(key.lengthOfBytes(using: .utf8)) + let msgLen = CUnsignedLong(msg.lengthOfBytes(using: .utf8)) + hmac(hash.rawValue, key, keyLen, msg, msgLen, mac) + return Data(bytesNoCopy: mac, count: hash.length, deallocator: .free) + } + + + private static let hmac: CCHmacFuncPtr = loadHMACfromCommonCrypto() + + // see + private typealias CCHmacFuncPtr = @convention(c) ( + _ algorithm: CUnsignedInt, + _ key: UnsafePointer, + _ keyLength: CUnsignedLong, + _ data: UnsafePointer, + _ dataLength: CUnsignedLong, + _ macOut: UnsafeMutablePointer + ) -> Void + + /// Just a `import CommonCrypto` would be great, but unfortunately this is still not possible. + /// So we use the only other sane method at this time to get access to CommonCrypto. + /// (Note: Since this is a lib, bridging headers are not supported. + /// Also modulemap files are error prone due to non relative file paths.) + /// + /// - Returns: A function pointer to CCHmac from libcommonCrypto + private static func loadHMACfromCommonCrypto() -> CCHmacFuncPtr + { + let libcc = dlopen("/usr/lib/system/libcommonCrypto.dylib", RTLD_NOW) + return unsafeBitCast(dlsym(libcc, "CCHmac"), to: CCHmacFuncPtr.self) + } +} + + +fileprivate extension URL +{ + /// Transforms: "www.x.com?color=red&age=29" to ["color": "red", "age": "29"] + func queryParameters() -> [String: String] + { + var res: [String: String] = [:] + for qi in URLComponents(url: self, resolvingAgainstBaseURL: true)?.queryItems ?? [] { + res[qi.name] = qi.value ?? "" + } + return res + } + + /// [RFC-5849 Section 3.4.1.2](https://tools.ietf.org/html/rfc5849#section-3.4.1.2) + func oAuthBaseURL() -> String + { + let scheme = self.scheme?.lowercased() ?? "" + let host = self.host?.lowercased() ?? "" + + var authority = "" + if let user = self.user, let pw = self.password { + authority = user + ":" + pw + "@" + } + else if let user = self.user { + authority = user + "@" + } + + var port = "" + if let iport = self.port, iport != 80, scheme == "http" { + port = ":\(iport)" + } + else if let iport = self.port, iport != 443, scheme == "https" { + port = ":\(iport)" + } + + return scheme + "://" + authority + host + port + self.path + } +} + + + diff --git a/Broadcast/TestAuthenticationProvider.swift b/Broadcast/TestAuthenticationProvider.swift index ae4867f..694d8d1 100644 --- a/Broadcast/TestAuthenticationProvider.swift +++ b/Broadcast/TestAuthenticationProvider.swift @@ -8,6 +8,7 @@ import Foundation import AuthenticationServices import Combine +import CommonCrypto class AuthenticationProvider: NSObject, ObservableObject, ASWebAuthenticationPresentationContextProviding { func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { @@ -16,45 +17,66 @@ class AuthenticationProvider: NSObject, ObservableObject, ASWebAuthenticationPre func requestAuthentication() async { let creds = TwitterClient.ClientCredentials.self + let callback = creds.callbackURL.absoluteString - guard let callbackURL = creds.callbackURL.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlFragmentAllowed) else { - return - } - - var requestTokenComponents = URLComponents() - - requestTokenComponents.scheme = "https" - requestTokenComponents.host = "api.twitter.com" - requestTokenComponents.path = "/2/oauth/request_token" - requestTokenComponents.queryItems = [ - URLQueryItem(name: "oauth_callback", value: callbackURL) - ] - - guard let requestTokenURL = requestTokenComponents.url else { - return - } + var urlRequest = URLRequest(url: URL(string: "https://api.twitter.com/oauth/request_token")!) - var requestTokenRequest = URLRequest(url: requestTokenURL) + urlRequest.oAuthSign(method: "POST", urlFormParameters: ["oauth_callback" : callback], consumerCredentials: (key: creds.apiKey, secret: creds.apiSecret)) - requestTokenRequest.httpMethod = "POST" - - let clientKey = creds.apiKey - let clientSecret = creds.apiSecret - let requestTokenAuthorizationHeader = """ -OAuth -oauth_consumer_key="\(clientKey)" -""" - - requestTokenRequest.setValue("", forHTTPHeaderField: "Authorization") + var oauthToken: String = "" + var oauthTokenSecret: String = "" do { - let (requestTokenData, _) = try await URLSession.shared.data(for: requestTokenRequest) + let (requestTokenData, _) = try await URLSession.shared.data(for: urlRequest) - let decoded = try JSONDecoder().decode(String.self, from: requestTokenData) + guard let response = String(data: requestTokenData, encoding: .utf8)?.urlQueryStringParameters, + let token = response["oauth_token"], + let tokenSecret = response["oauth_token_secret"] else { + return + } - print(decoded) + oauthToken = token + oauthTokenSecret = tokenSecret } catch { print(error.localizedDescription) } + + let authURL = URL(string: "https://api.twitter.com/oauth/authorize?oauth_token=\(oauthToken)")! + + let authSession = ASWebAuthenticationSession(url: authURL, callbackURLScheme: "https") { (url, error) in + if let error = error { + print(error.localizedDescription) + } else if let url = url { + print(url) + } + } + + authSession.presentationContextProvider = self + authSession.start() + } +} + +extension String { + var urlEncoded: String { + var charset: CharacterSet = .urlQueryAllowed + charset.remove(charactersIn: "\n:#/?@!$&'()*+,;=") + return self.addingPercentEncoding(withAllowedCharacters: charset)! + } +} + +extension String { + var urlQueryStringParameters: Dictionary { + // breaks apart query string into a dictionary of values + var params = [String: String]() + let items = self.split(separator: "&") + for item in items { + let combo = item.split(separator: "=") + if combo.count == 2 { + let key = "\(combo[0])" + let val = "\(combo[1])" + params[key] = val + } + } + return params } } From e0070d27426ab940f94f187360eaa388a57d922d Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Mon, 10 Jan 2022 19:25:09 +0000 Subject: [PATCH 03/36] Get API client basics working --- .../UserInterfaceState.xcuserstate | Bin 31324 -> 36716 bytes Broadcast/BroadcastApp.swift | 3 - Broadcast/ContentView.swift | 2 +- Broadcast/TestAuthenticationProvider.swift | 166 ++++++++++++++++-- 4 files changed, 157 insertions(+), 14 deletions(-) diff --git a/Broadcast.xcodeproj/project.xcworkspace/xcuserdata/dte.xcuserdatad/UserInterfaceState.xcuserstate b/Broadcast.xcodeproj/project.xcworkspace/xcuserdata/dte.xcuserdatad/UserInterfaceState.xcuserstate index 198d4a9b9e1e390d3b76f9ad23577f3b5d22a927..be902378320cc9cf2cd71213a8c2746b99d99588 100644 GIT binary patch delta 20183 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 X2*TZ1XoS#QZWQz~e|EgrO!)r*)K_wd diff --git a/Broadcast/BroadcastApp.swift b/Broadcast/BroadcastApp.swift index 7f8dc65..2be99a3 100644 --- a/Broadcast/BroadcastApp.swift +++ b/Broadcast/BroadcastApp.swift @@ -30,9 +30,6 @@ struct BroadcastApp: App { persistenceController.save() } - .task { - await AuthenticationProvider().requestAuthentication() - } VisualEffectView(effect: UIBlurEffect(style: .regular)) .frame(height: geom.safeAreaInsets.top) diff --git a/Broadcast/ContentView.swift b/Broadcast/ContentView.swift index 6560d94..e0a6721 100644 --- a/Broadcast/ContentView.swift +++ b/Broadcast/ContentView.swift @@ -62,7 +62,7 @@ struct ContentView: View { } } - if $twitterClient.user.wrappedValue != nil { + if twitterClient.user != nil { ComposerView(signOutScreenIsPresented: $signOutScreenIsPresented) .frame( height: geom.size.height - (bottomPadding + (captionSize * 2)) - imageHeightCompensation, diff --git a/Broadcast/TestAuthenticationProvider.swift b/Broadcast/TestAuthenticationProvider.swift index 694d8d1..dda016a 100644 --- a/Broadcast/TestAuthenticationProvider.swift +++ b/Broadcast/TestAuthenticationProvider.swift @@ -7,47 +7,186 @@ import Foundation import AuthenticationServices -import Combine +import SwiftUI import CommonCrypto +import SwiftKeychainWrapper +struct UserCredentials: Identifiable, Codable { + typealias ID = Int + var id: ID + var screenName: String + var oauthToken: String + var oauthTokenSecret: String + + enum CodingKeys: String, CodingKey { + case id = "user_id" + case screenName = "screen_name" + case oauthToken = "oauth_token" + case oauthTokenSecret = "oauth_token_secret" + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + let tempID = try values.decode(String.self, forKey: .id) + guard let intID = Int(tempID) else { + throw DecodingError.typeMismatch(Int.self, DecodingError.Context(codingPath: [], debugDescription: "Could not encode ID as Int")) + } + id = intID + screenName = try values.decode(String.self, forKey: .screenName) + oauthToken = try values.decode(String.self, forKey: .oauthToken) + oauthTokenSecret = try values.decode(String.self, forKey: .oauthTokenSecret) + } +} + +extension UserCredentials.ID { + var keychainIdentifier: String { + "broadcast-credentials-\(self)" + } +} + +typealias UserIDsArray = Set + +extension UserIDsArray: RawRepresentable { + public init?(rawValue: String) { + guard let data = rawValue.data(using: .utf8), + let result = try? JSONDecoder().decode(UserIDsArray.self, from: data) else { + return nil + } + + self = result + } + + public var rawValue: String { + guard let data = try? JSONEncoder().encode(self), + let result = String(data: data, encoding: .utf8) else { + return "[]" + } + + return result + } +} + +@MainActor class AuthenticationProvider: NSObject, ObservableObject, ASWebAuthenticationPresentationContextProviding { + private let creds = TwitterClient.ClientCredentials.self + @AppStorage("authenticatedUserIDs") var authenticatedUserIDs: UserIDsArray = [] func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { return ASPresentationAnchor() } + func userIsAuthorized(userID: UserCredentials.ID) async -> Bool { + do { + var urlRequest = URLRequest(url: URL(string: "https://api.twitter.com/2/users/me")!) + try signRequest(&urlRequest, userID: userID, method: "GET") + + let (data, _) = try await URLSession.shared.data(for: urlRequest) + + print(String(data: data, encoding: .utf8)) + return true + } catch { + print(error.localizedDescription) + return false + } + } + + /// Signs a URL request with the necessary authorization headers for a given user + /// - Parameters: + /// - urlRequest: The URL request to sign + /// - userID: The user's ID + /// - method: HTTP method for the request + /// - body: The body for the request + /// - contentType: The content type for the request + /// - Returns: The signed URL request + func signRequest(_ urlRequest: inout URLRequest, + userID: UserCredentials.ID, + method: String, + body: Data? = nil, + contentType: String? = nil + ) throws { + guard let userCredentialData = KeychainWrapper.standard.data(forKey: userID.keychainIdentifier) else { + throw RequestSigningError.MissingCredentials(forUserID: userID) + } + + guard let user = try? JSONDecoder().decode(UserCredentials.self, from: userCredentialData) else { + throw RequestSigningError.DecodingError + } + + urlRequest.oAuthSign( + method: method, + body: body, + contentType: contentType, + consumerCredentials: (key: creds.apiKey, secret: creds.apiSecret), + userCredentials: (key: user.oauthToken, secret: user.oauthTokenSecret) + ) + } + + /// Requests authorization via Twitter's three-step OAuth flow and stores user credentials for later use func requestAuthentication() async { - let creds = TwitterClient.ClientCredentials.self let callback = creds.callbackURL.absoluteString - var urlRequest = URLRequest(url: URL(string: "https://api.twitter.com/oauth/request_token")!) + // MARK: Step one: Obtain a request token + var stepOneRequest = URLRequest(url: URL(string: "https://api.twitter.com/oauth/request_token")!) - urlRequest.oAuthSign(method: "POST", urlFormParameters: ["oauth_callback" : callback], consumerCredentials: (key: creds.apiKey, secret: creds.apiSecret)) + stepOneRequest.oAuthSign( + method: "POST", + urlFormParameters: ["oauth_callback" : callback], + consumerCredentials: (key: creds.apiKey, secret: creds.apiSecret) + ) var oauthToken: String = "" - var oauthTokenSecret: String = "" do { - let (requestTokenData, _) = try await URLSession.shared.data(for: urlRequest) + let (requestTokenData, _) = try await URLSession.shared.data(for: stepOneRequest) guard let response = String(data: requestTokenData, encoding: .utf8)?.urlQueryStringParameters, - let token = response["oauth_token"], - let tokenSecret = response["oauth_token_secret"] else { + let token = response["oauth_token"] else { return } oauthToken = token - oauthTokenSecret = tokenSecret } catch { print(error.localizedDescription) } + // MARK: Step two: Redirecting the user let authURL = URL(string: "https://api.twitter.com/oauth/authorize?oauth_token=\(oauthToken)")! let authSession = ASWebAuthenticationSession(url: authURL, callbackURLScheme: "https") { (url, error) in if let error = error { print(error.localizedDescription) } else if let url = url { - print(url) + guard let queryItems = url.query?.urlQueryStringParameters, + let oauthToken = queryItems["oauth_token"], + let oauthVerifier = queryItems["oauth_verifier"] else { + return + } + + // MARK: Step three: Converting the request token into an access token + Task { + var stepThreeRequest = URLRequest(url: URL(string: "https://api.twitter.com/oauth/access_token?oauth_verifier=\(oauthVerifier)")!) + + stepThreeRequest.oAuthSign( + method: "POST", + urlFormParameters: ["oauth_token" : oauthToken], + consumerCredentials: (key: self.creds.apiKey, secret: self.creds.apiSecret) + ) + + let (data, _) = try await URLSession.shared.data(for: stepThreeRequest) + + guard let response = String(data: data, encoding: .utf8)?.urlQueryStringParameters, + let encoded = try? JSONEncoder().encode(response) else { + print("Failed to decode step three response: \(data.description)") + return + } + + do { + let user = try JSONDecoder().decode(UserCredentials.self, from: encoded) + KeychainWrapper.standard.set(encoded, forKey: user.id.keychainIdentifier) + self.authenticatedUserIDs.insert(user.id) + } catch { + print(error) + } + } } } @@ -80,3 +219,10 @@ extension String { return params } } + +extension AuthenticationProvider { + enum RequestSigningError: Error { + case DecodingError + case MissingCredentials(forUserID: UserCredentials.ID) + } +} From 66aa4242e29f9a2c2db5a2a9324df385a8230ad4 Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Tue, 11 Jan 2022 09:13:26 +0000 Subject: [PATCH 04/36] Begin generalising twitter lib code --- .../UserInterfaceState.xcuserstate | Bin 36716 -> 34749 bytes Broadcast/TestAuthenticationProvider.swift | 53 +++++++++++------- 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/Broadcast.xcodeproj/project.xcworkspace/xcuserdata/dte.xcuserdatad/UserInterfaceState.xcuserstate b/Broadcast.xcodeproj/project.xcworkspace/xcuserdata/dte.xcuserdatad/UserInterfaceState.xcuserstate index be902378320cc9cf2cd71213a8c2746b99d99588..1f2cbb259f77f70b77fc2f986df17a061bfa6f7e 100644 GIT binary patch delta 16622 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 ASPresentationAnchor { return ASPresentationAnchor() @@ -77,7 +91,7 @@ class AuthenticationProvider: NSObject, ObservableObject, ASWebAuthenticationPre func userIsAuthorized(userID: UserCredentials.ID) async -> Bool { do { var urlRequest = URLRequest(url: URL(string: "https://api.twitter.com/2/users/me")!) - try signRequest(&urlRequest, userID: userID, method: "GET") + try signRequest(&urlRequest, method: "GET") let (data, _) = try await URLSession.shared.data(for: urlRequest) @@ -98,31 +112,28 @@ class AuthenticationProvider: NSObject, ObservableObject, ASWebAuthenticationPre /// - contentType: The content type for the request /// - Returns: The signed URL request func signRequest(_ urlRequest: inout URLRequest, - userID: UserCredentials.ID, method: String, body: Data? = nil, contentType: String? = nil ) throws { - guard let userCredentialData = KeychainWrapper.standard.data(forKey: userID.keychainIdentifier) else { - throw RequestSigningError.MissingCredentials(forUserID: userID) - } - - guard let user = try? JSONDecoder().decode(UserCredentials.self, from: userCredentialData) else { - throw RequestSigningError.DecodingError + guard let userCredentials = userCredentials else { + throw RequestSigningError.MissingCredentials } - + urlRequest.oAuthSign( method: method, body: body, contentType: contentType, - consumerCredentials: (key: creds.apiKey, secret: creds.apiSecret), - userCredentials: (key: user.oauthToken, secret: user.oauthTokenSecret) + consumerCredentials: (key: clientCredentials.key, secret: clientCredentials.secret), + userCredentials: (key: userCredentials.key, secret: userCredentials.secret) ) } /// Requests authorization via Twitter's three-step OAuth flow and stores user credentials for later use - func requestAuthentication() async { - let callback = creds.callbackURL.absoluteString + func requestAuthentication() async throws { + guard let callback = callbackURL.scheme else { + throw TwiftError.CallbackURLError + } // MARK: Step one: Obtain a request token var stepOneRequest = URLRequest(url: URL(string: "https://api.twitter.com/oauth/request_token")!) @@ -130,7 +141,7 @@ class AuthenticationProvider: NSObject, ObservableObject, ASWebAuthenticationPre stepOneRequest.oAuthSign( method: "POST", urlFormParameters: ["oauth_callback" : callback], - consumerCredentials: (key: creds.apiKey, secret: creds.apiSecret) + consumerCredentials: (key: clientCredentials.key, secret: clientCredentials.secret) ) var oauthToken: String = "" @@ -168,7 +179,7 @@ class AuthenticationProvider: NSObject, ObservableObject, ASWebAuthenticationPre stepThreeRequest.oAuthSign( method: "POST", urlFormParameters: ["oauth_token" : oauthToken], - consumerCredentials: (key: self.creds.apiKey, secret: self.creds.apiSecret) + consumerCredentials: (key: self.clientCredentials.key, secret: self.clientCredentials.secret) ) let (data, _) = try await URLSession.shared.data(for: stepThreeRequest) @@ -220,9 +231,13 @@ extension String { } } -extension AuthenticationProvider { +extension Twift { enum RequestSigningError: Error { case DecodingError - case MissingCredentials(forUserID: UserCredentials.ID) + case MissingCredentials + } + + enum TwiftError: Error { + case CallbackURLError } } From de21ea07801134bcb54d7a5fc74ec698e7f16d11 Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Tue, 11 Jan 2022 09:33:59 +0000 Subject: [PATCH 05/36] Remove .xcuserstate --- .gitignore | 1 + .../UserInterfaceState.xcuserstate | Bin 34749 -> 0 bytes 2 files changed, 1 insertion(+) delete mode 100644 Broadcast.xcodeproj/project.xcworkspace/xcuserdata/dte.xcuserdatad/UserInterfaceState.xcuserstate diff --git a/.gitignore b/.gitignore index 0f016b4..418e5d0 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ TwitterAPI-Info.plist +**/*.xcuserstate diff --git a/Broadcast.xcodeproj/project.xcworkspace/xcuserdata/dte.xcuserdatad/UserInterfaceState.xcuserstate b/Broadcast.xcodeproj/project.xcworkspace/xcuserdata/dte.xcuserdatad/UserInterfaceState.xcuserstate deleted file mode 100644 index 1f2cbb259f77f70b77fc2f986df17a061bfa6f7e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 34749 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 Date: Tue, 11 Jan 2022 10:47:51 +0000 Subject: [PATCH 06/36] Add Twift as package dependency --- Broadcast.xcodeproj/project.pbxproj | 25 +- .../xcshareddata/swiftpm/Package.resolved | 9 + Broadcast/Extensions/URLRequest+OAuth.swift | 318 ------------------ Broadcast/TestAuthenticationProvider.swift | 243 ------------- 4 files changed, 26 insertions(+), 569 deletions(-) delete mode 100644 Broadcast/Extensions/URLRequest+OAuth.swift delete mode 100644 Broadcast/TestAuthenticationProvider.swift diff --git a/Broadcast.xcodeproj/project.pbxproj b/Broadcast.xcodeproj/project.pbxproj index dc996b0..7188ff1 100644 --- a/Broadcast.xcodeproj/project.pbxproj +++ b/Broadcast.xcodeproj/project.pbxproj @@ -10,7 +10,7 @@ 7101073626C810AC00A713A5 /* NullStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7101073526C810AC00A713A5 /* NullStateView.swift */; }; 711EF99426C959A700FD8A9F /* BroadcastUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711EF99326C959A700FD8A9F /* BroadcastUITests.swift */; }; 711F3FFA268F50C800605C89 /* Animation.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711F3FF9268F50C800605C89 /* Animation.extension.swift */; }; - 712EF6E0278C8B60007C09F6 /* URLRequest+OAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 712EF6DF278C8B60007C09F6 /* URLRequest+OAuth.swift */; }; + 714782D5278D97AB00942618 /* Twift in Frameworks */ = {isa = PBXBuildFile; productRef = 714782D4278D97AB00942618 /* Twift */; }; 715AAE0A26C923A1002BCEA1 /* Swifter in Frameworks */ = {isa = PBXBuildFile; productRef = 715AAE0926C923A1002BCEA1 /* Swifter */; }; 715AAE0C26C9589A002BCEA1 /* TestUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 715AAE0B26C9589A002BCEA1 /* TestUtils.swift */; }; 717041F326B6FFEA00001360 /* RepliesListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 717041F226B6FFEA00001360 /* RepliesListView.swift */; }; @@ -49,7 +49,6 @@ 7199AE8026B9892E001DEB46 /* Debouncer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7199AE7F26B9892E001DEB46 /* Debouncer.swift */; }; 71A1088A268B073B007E1FFB /* Haptics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71A10889268B073B007E1FFB /* Haptics.swift */; }; 71A6A264278C73AD00BF2387 /* TwitterAPI-Info.example.plist in Resources */ = {isa = PBXBuildFile; fileRef = 71A6A263278C73AD00BF2387 /* TwitterAPI-Info.example.plist */; }; - 71A6A266278C784C00BF2387 /* TestAuthenticationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71A6A265278C784C00BF2387 /* TestAuthenticationProvider.swift */; }; 71AA4AC6268A032400B7B577 /* RemoteImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71AA4AC5268A032400B7B577 /* RemoteImage.swift */; }; 71B8290C268D0AC6002AEE72 /* TwitterClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71B8290B268D0AC6002AEE72 /* TwitterClient.swift */; }; 71B82910268F2195002AEE72 /* LastTweetReplyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71B8290F268F2195002AEE72 /* LastTweetReplyView.swift */; }; @@ -74,7 +73,6 @@ 711EF99326C959A700FD8A9F /* BroadcastUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastUITests.swift; sourceTree = ""; }; 711EF99526C959A700FD8A9F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 711F3FF9268F50C800605C89 /* Animation.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Animation.extension.swift; sourceTree = ""; }; - 712EF6DF278C8B60007C09F6 /* URLRequest+OAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLRequest+OAuth.swift"; sourceTree = ""; }; 715AAE0B26C9589A002BCEA1 /* TestUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestUtils.swift; sourceTree = ""; }; 717041F226B6FFEA00001360 /* RepliesListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepliesListView.swift; sourceTree = ""; }; 717041F426B7037300001360 /* TweetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TweetView.swift; sourceTree = ""; }; @@ -111,7 +109,6 @@ 7199AE7F26B9892E001DEB46 /* Debouncer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debouncer.swift; sourceTree = ""; }; 71A10889268B073B007E1FFB /* Haptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Haptics.swift; sourceTree = ""; }; 71A6A263278C73AD00BF2387 /* TwitterAPI-Info.example.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "TwitterAPI-Info.example.plist"; sourceTree = ""; }; - 71A6A265278C784C00BF2387 /* TestAuthenticationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAuthenticationProvider.swift; sourceTree = ""; }; 71AA4AC5268A032400B7B577 /* RemoteImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImage.swift; sourceTree = ""; }; 71B8290B268D0AC6002AEE72 /* TwitterClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwitterClient.swift; sourceTree = ""; }; 71B8290F268F2195002AEE72 /* LastTweetReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastTweetReplyView.swift; sourceTree = ""; }; @@ -135,6 +132,7 @@ 7188E65D26887DCD007CFD78 /* SwiftKeychainWrapper in Frameworks */, 7188E66A2688D7BA007CFD78 /* Introspect in Frameworks */, 719087CD26891586005B96CE /* TwitterText in Frameworks */, + 714782D5278D97AB00942618 /* Twift in Frameworks */, 715AAE0A26C923A1002BCEA1 /* Swifter in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -185,7 +183,6 @@ 71BBAAEA268CF532004048A0 /* TwitterAPI-Info.plist */, 71A6A263278C73AD00BF2387 /* TwitterAPI-Info.example.plist */, 71800BFB26999B1B009D11A1 /* DraftsModel.xcdatamodeld */, - 71A6A265278C784C00BF2387 /* TestAuthenticationProvider.swift */, ); path = Broadcast; sourceTree = ""; @@ -210,7 +207,6 @@ 71800BF7269998BF009D11A1 /* UIImage.extension.swift */, 717041F626B703A600001360 /* TwitterClient+MockTweet.swift */, 7199AE7926B96D0D001DEB46 /* NSRegularExpression+Convenience.swift */, - 712EF6DF278C8B60007C09F6 /* URLRequest+OAuth.swift */, ); path = Extensions; sourceTree = ""; @@ -293,6 +289,7 @@ 7188E6692688D7BA007CFD78 /* Introspect */, 719087CC26891586005B96CE /* TwitterText */, 715AAE0926C923A1002BCEA1 /* Swifter */, + 714782D4278D97AB00942618 /* Twift */, ); productName = Broadcast; productReference = 7188E6252687B0FE007CFD78 /* Broadcast.app */; @@ -330,6 +327,7 @@ 7188E6682688D7BA007CFD78 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, 719087CB26891586005B96CE /* XCRemoteSwiftPackageReference "twitter-text" */, 715AAE0826C923A1002BCEA1 /* XCRemoteSwiftPackageReference "Swifter" */, + 714782D3278D97AB00942618 /* XCRemoteSwiftPackageReference "Twift" */, ); productRefGroup = 7188E6262687B0FE007CFD78 /* Products */; projectDirPath = ""; @@ -396,13 +394,11 @@ buildActionMask = 2147483647; files = ( 7188E6612688A01F007CFD78 /* Font.extension.swift in Sources */, - 712EF6E0278C8B60007C09F6 /* URLRequest+OAuth.swift in Sources */, 7188E62B2687B0FE007CFD78 /* ContentView.swift in Sources */, 717041F526B7037300001360 /* TweetView.swift in Sources */, 7188E63B2687B19D007CFD78 /* Notification.extension.swift in Sources */, 717041F326B6FFEA00001360 /* RepliesListView.swift in Sources */, 7188E6292687B0FE007CFD78 /* BroadcastApp.swift in Sources */, - 71A6A266278C784C00BF2387 /* TestAuthenticationProvider.swift in Sources */, 71B8290C268D0AC6002AEE72 /* TwitterClient.swift in Sources */, 7188E6632688A0FC007CFD78 /* WelcomeView.swift in Sources */, 7199AE7A26B96D0D001DEB46 /* NSRegularExpression+Convenience.swift in Sources */, @@ -684,6 +680,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + 714782D3278D97AB00942618 /* XCRemoteSwiftPackageReference "Twift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/daneden/Twift.git"; + requirement = { + branch = main; + kind = branch; + }; + }; 715AAE0826C923A1002BCEA1 /* XCRemoteSwiftPackageReference "Swifter" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/daneden/Swifter"; @@ -719,6 +723,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 714782D4278D97AB00942618 /* Twift */ = { + isa = XCSwiftPackageProductDependency; + package = 714782D3278D97AB00942618 /* XCRemoteSwiftPackageReference "Twift" */; + productName = Twift; + }; 715AAE0926C923A1002BCEA1 /* Swifter */ = { isa = XCSwiftPackageProductDependency; package = 715AAE0826C923A1002BCEA1 /* XCRemoteSwiftPackageReference "Swifter" */; diff --git a/Broadcast.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Broadcast.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index c92d616..9802d47 100644 --- a/Broadcast.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Broadcast.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -28,6 +28,15 @@ "version": "0.1.3" } }, + { + "package": "Twift", + "repositoryURL": "https://github.com/daneden/Twift.git", + "state": { + "branch": "main", + "revision": "0a620fb27248dae40f6b6b3aa2d3060a4d30a466", + "version": null + } + }, { "package": "twitter-text", "repositoryURL": "https://github.com/nysander/twitter-text.git", diff --git a/Broadcast/Extensions/URLRequest+OAuth.swift b/Broadcast/Extensions/URLRequest+OAuth.swift deleted file mode 100644 index 68dbe8e..0000000 --- a/Broadcast/Extensions/URLRequest+OAuth.swift +++ /dev/null @@ -1,318 +0,0 @@ -// -// URLRequest+OAuth.swift -// Broadcast -// -// Created by Daniel Eden on 10/01/2022. -// - -// -// Apache License, Version 2.0 -// -// Copyright 2017, Markus Wanke -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -/// # OhhAuth -/// ## Pure Swift implementation of the OAuth 1.0 protocol as an easy to use extension for the URLRequest type. -/// - Author: Markus Wanke -/// - Copyright: 2017 - -import Foundation - -public class OhhAuth -{ - /// Tuple to represent signing credentials. (consumer as well as user credentials) - public typealias Credentials = (key: String, secret: String) - - - /// Function to calculate the OAuth protocol parameters and signature ready to be added - /// as the HTTP header "Authorization" entry. A detailed explanation of the procedure - /// can be found at: [RFC-5849 Section 3](https://tools.ietf.org/html/rfc5849#section-3) - /// - /// - Parameters: - /// - url: Request url (with all query parameters etc.) - /// - method: HTTP method - /// - parameter: url-form parameters - /// - consumerCredentials: consumer credentials - /// - userCredentials: user credentials (nil if this is a request without user association) - /// - /// - Returns: OAuth HTTP header entry for the Authorization field. - static func calculateSignature(url: URL, method: String, parameter: [String: String], - consumerCredentials cc: Credentials, userCredentials uc: Credentials?) -> String - { - typealias Tup = (key: String, value: String) - - let tuplify: (String, String) -> Tup = { - return (key: rfc3986encode($0), value: rfc3986encode($1)) - } - let cmp: (Tup, Tup) -> Bool = { - return $0.key < $1.key - } - let toPairString: (Tup) -> String = { - return $0.key + "=" + $0.value - } - let toBrackyPairString: (Tup) -> String = { - return $0.key + "=\"" + $0.value + "\"" - } - - /// [RFC-5849 Section 3.1](https://tools.ietf.org/html/rfc5849#section-3.1) - var oAuthParameters = oAuthDefaultParameters(consumerKey: cc.key, userKey: uc?.key) - - /// [RFC-5849 Section 3.4.1.3.1](https://tools.ietf.org/html/rfc5849#section-3.4.1.3.1) - let signString: String = [oAuthParameters, parameter, url.queryParameters()] - .flatMap { $0.map(tuplify) } - .sorted(by: cmp) - .map(toPairString) - .joined(separator: "&") - - - /// [RFC-5849 Section 3.4.1](https://tools.ietf.org/html/rfc5849#section-3.4.1) - let signatureBase: String = [method, url.oAuthBaseURL(), signString] - .map(rfc3986encode) - .joined(separator: "&") - - /// [RFC-5849 Section 3.4.2](https://tools.ietf.org/html/rfc5849#section-3.4.2) - let signingKey: String = [cc.secret, uc?.secret ?? ""] - .map(rfc3986encode) - .joined(separator: "&") - - /// [RFC-5849 Section 3.4.2](https://tools.ietf.org/html/rfc5849#section-3.4.2) - let binarySignature = HMAC.calculate(withHash: .sha1, key: signingKey, message: signatureBase) - oAuthParameters["oauth_signature"] = binarySignature.base64EncodedString() - - /// [RFC-5849 Section 3.5.1](https://tools.ietf.org/html/rfc5849#section-3.5.1) - return "OAuth " + oAuthParameters - .map(tuplify) - .sorted(by: cmp) - .map(toBrackyPairString) - .joined(separator: ",") - } - - - - /// Function to perform the right percentage encoding for url form parameters. - /// - /// - Parameter paras: url-form parameters - /// - Parameter encoding: used string encoding (default: .utf8) - /// - Returns: correctly percentage encoded url-form parameters - static func httpBody(forFormParameters paras: [String: String], encoding: String.Encoding = .utf8) -> Data? - { - let trans: (String, String) -> String = { k, v in - return rfc3986encode(k) + "=" + rfc3986encode(v) - } - - return paras.map(trans).joined(separator: "&").data(using: encoding) - } - - /// OAuth cites RFC-3986 for percentage encoding. - /// Characters that don't need to be converted are: ALPHA, DIGIT, "-", ".", "_", "~" - /// [RFC-5849 Section 3.6](https://tools.ietf.org/html/rfc5849#section-3.6) - /// [RFC-3986 Section 2.3](https://tools.ietf.org/html/rfc3986#section-2.3) - /// Predefined CharacterSets are not used to be 100% RFC conform and - /// avoid possible unicode conversion problems. - private static func rfc3986encode(_ str: String) -> String - { - struct Static { - static let allowed = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-._~" - static let allowedSet = CharacterSet(charactersIn: allowed) - } - return str.addingPercentEncoding(withAllowedCharacters: Static.allowedSet) ?? str - } - - private static func oAuthDefaultParameters(consumerKey: String, userKey: String?) -> [String: String] - { - /// [RFC-5849 Section 3.1](https://tools.ietf.org/html/rfc5849#section-3.1) - var defaults: [String: String] = [ - "oauth_consumer_key": consumerKey, - "oauth_signature_method": "HMAC-SHA1", - "oauth_version": "1.0", - /// [RFC-5849 Section 3.3](https://tools.ietf.org/html/rfc5849#section-3.3) - "oauth_timestamp": String(Int(Date().timeIntervalSince1970)), - "oauth_nonce": UUID().uuidString, - ] - if let userKey = userKey { - defaults["oauth_token"] = userKey - } - return defaults - } -} - - -public extension URLRequest -{ - /// Easy to use method to sign a URLRequest which includes url-form parameters with OAuth. - /// The request needs a valid URL with all query parameters etc. included. - /// After calling this method the HTTP header fields: "Authorization", "Content-Type" - /// and "Content-Length" should not be overwritten. - /// - /// - Parameters: - /// - method: HTTP Method - /// - paras: url-form parameters - /// - consumerCredentials: consumer credentials - /// - userCredentials: user credentials (nil if this is a request without user association) - mutating func oAuthSign(method: String, urlFormParameters paras: [String: String], - consumerCredentials cc: OhhAuth.Credentials, userCredentials uc: OhhAuth.Credentials? = nil) - { - self.httpMethod = method.uppercased() - - let body = OhhAuth.httpBody(forFormParameters: paras) - - self.httpBody = body - self.addValue(String(body?.count ?? 0), forHTTPHeaderField: "Content-Length") - self.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - - let sig = OhhAuth.calculateSignature(url: self.url!, method: self.httpMethod!, - parameter: paras, consumerCredentials: cc, userCredentials: uc) - - self.addValue(sig, forHTTPHeaderField: "Authorization") - } - - /// Easy to use method to sign a URLRequest which includes plain body data with OAuth. - /// The request needs a valid URL with all query parameters etc. included. - /// After calling this method the HTTP header fields: "Authorization", "Content-Type" - /// and "Content-Length" should not be overwritten. - /// - /// - Parameters: - /// - method: HTTP Method - /// - body: HTTP request body (default: nil) - /// - contentType: HTTP header "Content-Type" entry (default: nil) - /// - consumerCredentials: consumer credentials - /// - userCredentials: user credentials (nil if this is a request without user association) - mutating func oAuthSign(method: String, body: Data? = nil, contentType: String? = nil, - consumerCredentials cc: OhhAuth.Credentials, userCredentials uc: OhhAuth.Credentials? = nil) - { - self.httpMethod = method.uppercased() - - if let body = body { - self.httpBody = body - self.addValue(String(body.count), forHTTPHeaderField: "Content-Length") - } - - if let ct = contentType { - self.addValue(ct, forHTTPHeaderField: "Content-Type") - } - - let sig = OhhAuth.calculateSignature(url: self.url!, method: self.httpMethod!, - parameter: [:], consumerCredentials: cc, userCredentials: uc) - - self.addValue(sig, forHTTPHeaderField: "Authorization") - } -} - - - -/// Hash-based message authentication helper class. -fileprivate class HMAC -{ - enum HashMethod: UInt32 - { - /// See - case sha1, md5, sha256, sha384, sha512, sha224 - - var length: Int { - switch self { - case .md5: return 16 - case .sha1: return 20 - case .sha224: return 28 - case .sha256: return 32 - case .sha384: return 48 - case .sha512: return 64 - } - } - } - - - /// Function to calculate a hash-based message authentication code (aka HMAC) - /// - /// - Parameters: - /// - withHash: hash function used (one of: .sha1, .md5, .sha256, .sha384, .sha512, .sha224) - /// - key: the key - /// - message: the message - /// - Returns: the HMAC - static func calculate(withHash hash: HashMethod, key: String, message msg: String) -> Data - { - let mac = UnsafeMutablePointer.allocate(capacity: hash.length) - let keyLen = CUnsignedLong(key.lengthOfBytes(using: .utf8)) - let msgLen = CUnsignedLong(msg.lengthOfBytes(using: .utf8)) - hmac(hash.rawValue, key, keyLen, msg, msgLen, mac) - return Data(bytesNoCopy: mac, count: hash.length, deallocator: .free) - } - - - private static let hmac: CCHmacFuncPtr = loadHMACfromCommonCrypto() - - // see - private typealias CCHmacFuncPtr = @convention(c) ( - _ algorithm: CUnsignedInt, - _ key: UnsafePointer, - _ keyLength: CUnsignedLong, - _ data: UnsafePointer, - _ dataLength: CUnsignedLong, - _ macOut: UnsafeMutablePointer - ) -> Void - - /// Just a `import CommonCrypto` would be great, but unfortunately this is still not possible. - /// So we use the only other sane method at this time to get access to CommonCrypto. - /// (Note: Since this is a lib, bridging headers are not supported. - /// Also modulemap files are error prone due to non relative file paths.) - /// - /// - Returns: A function pointer to CCHmac from libcommonCrypto - private static func loadHMACfromCommonCrypto() -> CCHmacFuncPtr - { - let libcc = dlopen("/usr/lib/system/libcommonCrypto.dylib", RTLD_NOW) - return unsafeBitCast(dlsym(libcc, "CCHmac"), to: CCHmacFuncPtr.self) - } -} - - -fileprivate extension URL -{ - /// Transforms: "www.x.com?color=red&age=29" to ["color": "red", "age": "29"] - func queryParameters() -> [String: String] - { - var res: [String: String] = [:] - for qi in URLComponents(url: self, resolvingAgainstBaseURL: true)?.queryItems ?? [] { - res[qi.name] = qi.value ?? "" - } - return res - } - - /// [RFC-5849 Section 3.4.1.2](https://tools.ietf.org/html/rfc5849#section-3.4.1.2) - func oAuthBaseURL() -> String - { - let scheme = self.scheme?.lowercased() ?? "" - let host = self.host?.lowercased() ?? "" - - var authority = "" - if let user = self.user, let pw = self.password { - authority = user + ":" + pw + "@" - } - else if let user = self.user { - authority = user + "@" - } - - var port = "" - if let iport = self.port, iport != 80, scheme == "http" { - port = ":\(iport)" - } - else if let iport = self.port, iport != 443, scheme == "https" { - port = ":\(iport)" - } - - return scheme + "://" + authority + host + port + self.path - } -} - - - diff --git a/Broadcast/TestAuthenticationProvider.swift b/Broadcast/TestAuthenticationProvider.swift deleted file mode 100644 index 687b9dc..0000000 --- a/Broadcast/TestAuthenticationProvider.swift +++ /dev/null @@ -1,243 +0,0 @@ -// -// TestAuthenticationProvider.swift -// Broadcast -// -// Created by Daniel Eden on 10/01/2022. -// - -import Foundation -import AuthenticationServices -import SwiftUI -import CommonCrypto -import SwiftKeychainWrapper - -struct UserCredentials: Identifiable, Codable { - typealias ID = Int - var id: ID - var screenName: String - var oauthToken: String - var oauthTokenSecret: String - - enum CodingKeys: String, CodingKey { - case id = "user_id" - case screenName = "screen_name" - case oauthToken = "oauth_token" - case oauthTokenSecret = "oauth_token_secret" - } - - init(from decoder: Decoder) throws { - let values = try decoder.container(keyedBy: CodingKeys.self) - let tempID = try values.decode(String.self, forKey: .id) - guard let intID = Int(tempID) else { - throw DecodingError.typeMismatch(Int.self, DecodingError.Context(codingPath: [], debugDescription: "Could not encode ID as Int")) - } - id = intID - screenName = try values.decode(String.self, forKey: .screenName) - oauthToken = try values.decode(String.self, forKey: .oauthToken) - oauthTokenSecret = try values.decode(String.self, forKey: .oauthTokenSecret) - } -} - -extension UserCredentials.ID { - var keychainIdentifier: String { - "broadcast-credentials-\(self)" - } -} - -typealias UserIDsArray = Set - -extension UserIDsArray: RawRepresentable { - public init?(rawValue: String) { - guard let data = rawValue.data(using: .utf8), - let result = try? JSONDecoder().decode(UserIDsArray.self, from: data) else { - return nil - } - - self = result - } - - public var rawValue: String { - guard let data = try? JSONEncoder().encode(self), - let result = String(data: data, encoding: .utf8) else { - return "[]" - } - - return result - } -} - -struct OAuthToken: Codable { - var key: String - var secret: String -} - -@MainActor -class Twift: NSObject, ObservableObject, ASWebAuthenticationPresentationContextProviding { - var clientCredentials: OAuthToken - var userCredentials: OAuthToken? - var callbackURL: URL - - init(clientCredentials: OAuthToken, userCredentials: OAuthToken?, callbackURL: URL) { - self.clientCredentials = clientCredentials - self.userCredentials = userCredentials - self.callbackURL = callbackURL - } - - @AppStorage("authenticatedUserIDs") var authenticatedUserIDs: UserIDsArray = [] - func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { - return ASPresentationAnchor() - } - - func userIsAuthorized(userID: UserCredentials.ID) async -> Bool { - do { - var urlRequest = URLRequest(url: URL(string: "https://api.twitter.com/2/users/me")!) - try signRequest(&urlRequest, method: "GET") - - let (data, _) = try await URLSession.shared.data(for: urlRequest) - - print(String(data: data, encoding: .utf8)) - return true - } catch { - print(error.localizedDescription) - return false - } - } - - /// Signs a URL request with the necessary authorization headers for a given user - /// - Parameters: - /// - urlRequest: The URL request to sign - /// - userID: The user's ID - /// - method: HTTP method for the request - /// - body: The body for the request - /// - contentType: The content type for the request - /// - Returns: The signed URL request - func signRequest(_ urlRequest: inout URLRequest, - method: String, - body: Data? = nil, - contentType: String? = nil - ) throws { - guard let userCredentials = userCredentials else { - throw RequestSigningError.MissingCredentials - } - - urlRequest.oAuthSign( - method: method, - body: body, - contentType: contentType, - consumerCredentials: (key: clientCredentials.key, secret: clientCredentials.secret), - userCredentials: (key: userCredentials.key, secret: userCredentials.secret) - ) - } - - /// Requests authorization via Twitter's three-step OAuth flow and stores user credentials for later use - func requestAuthentication() async throws { - guard let callback = callbackURL.scheme else { - throw TwiftError.CallbackURLError - } - - // MARK: Step one: Obtain a request token - var stepOneRequest = URLRequest(url: URL(string: "https://api.twitter.com/oauth/request_token")!) - - stepOneRequest.oAuthSign( - method: "POST", - urlFormParameters: ["oauth_callback" : callback], - consumerCredentials: (key: clientCredentials.key, secret: clientCredentials.secret) - ) - - var oauthToken: String = "" - - do { - let (requestTokenData, _) = try await URLSession.shared.data(for: stepOneRequest) - - guard let response = String(data: requestTokenData, encoding: .utf8)?.urlQueryStringParameters, - let token = response["oauth_token"] else { - return - } - - oauthToken = token - } catch { - print(error.localizedDescription) - } - - // MARK: Step two: Redirecting the user - let authURL = URL(string: "https://api.twitter.com/oauth/authorize?oauth_token=\(oauthToken)")! - - let authSession = ASWebAuthenticationSession(url: authURL, callbackURLScheme: "https") { (url, error) in - if let error = error { - print(error.localizedDescription) - } else if let url = url { - guard let queryItems = url.query?.urlQueryStringParameters, - let oauthToken = queryItems["oauth_token"], - let oauthVerifier = queryItems["oauth_verifier"] else { - return - } - - // MARK: Step three: Converting the request token into an access token - Task { - var stepThreeRequest = URLRequest(url: URL(string: "https://api.twitter.com/oauth/access_token?oauth_verifier=\(oauthVerifier)")!) - - stepThreeRequest.oAuthSign( - method: "POST", - urlFormParameters: ["oauth_token" : oauthToken], - consumerCredentials: (key: self.clientCredentials.key, secret: self.clientCredentials.secret) - ) - - let (data, _) = try await URLSession.shared.data(for: stepThreeRequest) - - guard let response = String(data: data, encoding: .utf8)?.urlQueryStringParameters, - let encoded = try? JSONEncoder().encode(response) else { - print("Failed to decode step three response: \(data.description)") - return - } - - do { - let user = try JSONDecoder().decode(UserCredentials.self, from: encoded) - KeychainWrapper.standard.set(encoded, forKey: user.id.keychainIdentifier) - self.authenticatedUserIDs.insert(user.id) - } catch { - print(error) - } - } - } - } - - authSession.presentationContextProvider = self - authSession.start() - } -} - -extension String { - var urlEncoded: String { - var charset: CharacterSet = .urlQueryAllowed - charset.remove(charactersIn: "\n:#/?@!$&'()*+,;=") - return self.addingPercentEncoding(withAllowedCharacters: charset)! - } -} - -extension String { - var urlQueryStringParameters: Dictionary { - // breaks apart query string into a dictionary of values - var params = [String: String]() - let items = self.split(separator: "&") - for item in items { - let combo = item.split(separator: "=") - if combo.count == 2 { - let key = "\(combo[0])" - let val = "\(combo[1])" - params[key] = val - } - } - return params - } -} - -extension Twift { - enum RequestSigningError: Error { - case DecodingError - case MissingCredentials - } - - enum TwiftError: Error { - case CallbackURLError - } -} From 00f73346c1f8bad082a6260c4ece8376f0b35fb6 Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Thu, 27 Jan 2022 10:19:02 +0000 Subject: [PATCH 07/36] Get initial Twift integration working Still need to do: - Fix media tweeting - Fix viewing replies to tweet - Fix sign out view --- .../xcshareddata/swiftpm/Package.resolved | 2 +- .../xcdebugger/Breakpoints_v2.xcbkptlist | 34 ++ Broadcast/BroadcastApp.swift | 4 - Broadcast/ContentView.swift | 10 +- .../Extensions/TwitterClient+MockTweet.swift | 41 +- Broadcast/Helper Views/ActionBarView.swift | 20 +- Broadcast/Helper Views/ComposerView.swift | 35 +- .../Helper Views/EngagementCountersView.swift | 11 +- .../Helper Views/LastTweetReplyView.swift | 13 +- Broadcast/Helper Views/MentionBar.swift | 11 +- Broadcast/Helper Views/RepliesListView.swift | 30 +- Broadcast/Helper Views/TweetView.swift | 23 +- Broadcast/Helper Views/UserView.swift | 22 +- Broadcast/Helpers/TwitterClient.swift | 363 +++++------------- Broadcast/SignOutView.swift | 9 +- 15 files changed, 241 insertions(+), 387 deletions(-) diff --git a/Broadcast.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Broadcast.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 9802d47..aef562c 100644 --- a/Broadcast.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Broadcast.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -33,7 +33,7 @@ "repositoryURL": "https://github.com/daneden/Twift.git", "state": { "branch": "main", - "revision": "0a620fb27248dae40f6b6b3aa2d3060a4d30a466", + "revision": "b995245d41c600d107759f292e6ef37688734ef4", "version": null } }, diff --git a/Broadcast.xcodeproj/xcuserdata/dte.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/Broadcast.xcodeproj/xcuserdata/dte.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist index 853662d..fa8e481 100644 --- a/Broadcast.xcodeproj/xcuserdata/dte.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +++ b/Broadcast.xcodeproj/xcuserdata/dte.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -3,4 +3,38 @@ uuid = "561759D3-8D3F-4CFB-B9B4-4D047A58B1B1" type = "1" version = "2.0"> + + + + + + + + + + diff --git a/Broadcast/BroadcastApp.swift b/Broadcast/BroadcastApp.swift index 2be99a3..508629d 100644 --- a/Broadcast/BroadcastApp.swift +++ b/Broadcast/BroadcastApp.swift @@ -24,10 +24,6 @@ struct BroadcastApp: App { .environment(\.managedObjectContext, persistenceController.container.viewContext) .accentColor(themeHelper.color) .onChange(of: scenePhase) { newPhase in - if newPhase == .active { - twitterClient.revalidateAccount() - } - persistenceController.save() } diff --git a/Broadcast/ContentView.swift b/Broadcast/ContentView.swift index e0a6721..899a541 100644 --- a/Broadcast/ContentView.swift +++ b/Broadcast/ContentView.swift @@ -8,6 +8,7 @@ import SwiftUI import Introspect import TwitterText +import Twift struct ContentView: View { @ScaledMetric private var captionSize: CGFloat = 14 @@ -33,7 +34,7 @@ struct ContentView: View { GeometryReader { geom in ZStack(alignment: .bottom) { ScrollView { - VStack { + VStack(spacing: 8) { if replying, let lastTweet = twitterClient.lastTweet { LastTweetReplyView(lastTweet: lastTweet) .background(GeometryReader { geometry in @@ -69,7 +70,7 @@ struct ContentView: View { alignment: .topLeading ) - AttachmentThumbnail(image: $twitterClient.draft.media) +// AttachmentThumbnail(image: $twitterClient.draft.media) } else { WelcomeView() } @@ -111,11 +112,8 @@ struct ContentView: View { .onAppear { UITextView.appearance().backgroundColor = .clear } - .onChange(of: replying) { _ in - twitterClient.revalidateAccount() - } .onPreferenceChange(ReplyBoxSizePreferenceKey.self) { newValue in - withAnimation(.easeInOut(duration: 0.1)) { replyBoxHeight = newValue } + withAnimation(.springAnimation) { replyBoxHeight = newValue + 8 } } } } diff --git a/Broadcast/Extensions/TwitterClient+MockTweet.swift b/Broadcast/Extensions/TwitterClient+MockTweet.swift index 3736fae..3ef7eaa 100644 --- a/Broadcast/Extensions/TwitterClient+MockTweet.swift +++ b/Broadcast/Extensions/TwitterClient+MockTweet.swift @@ -6,27 +6,32 @@ // import Foundation +import Twift -extension TwitterClient.Tweet { - static var mockTweet: TwitterClient.Tweet { - TwitterClient.Tweet( - numericId: 0, - id: "0", - text: "just setting up my twttr", - likes: 420, - retweets: 69, - date: Date(), - author: .mockUser - ) +extension Tweet { + static var mockTweet: Tweet { + let jsonString = """ +{ + "id": "0", +"text": "just setting up my twttr", +"createdAt": \(Date().timeIntervalSince1970), +"authorId": "0" +} +""" + return try! JSONDecoder().decode(Tweet.self, from: jsonString.data(using: .utf8)!) } } -extension TwitterClient.User { - static var mockUser: TwitterClient.User { - TwitterClient.User( - id: "0", - screenName: "_dte", - originalProfileImageURL: URL(string: "https://pbs.twimg.com/profile_images/1337359860409790469/javRMXyG_x96.jpg")! - ) +extension User { + static var mockUser: User { + let jsonString = """ +{ + "id": "0", +"name": "Daniel Eden", +"username": "_dte", +"profileImageUrl": "https://pbs.twimg.com/profile_images/1337359860409790469/javRMXyG_x96.jpg" +} +""" + return try! JSONDecoder().decode(User.self, from: jsonString.data(using: .utf8)!) } } diff --git a/Broadcast/Helper Views/ActionBarView.swift b/Broadcast/Helper Views/ActionBarView.swift index e3da473..10083ea 100644 --- a/Broadcast/Helper Views/ActionBarView.swift +++ b/Broadcast/Helper Views/ActionBarView.swift @@ -17,17 +17,19 @@ struct ActionBarView: View { var body: some View { publishingActions .disabled(twitterClient.state == .busy) - .sheet(isPresented: $photoPickerIsPresented) { - ImagePicker(chosenImage: $twitterClient.draft.media) - } +// .sheet(isPresented: $photoPickerIsPresented) { +// ImagePicker(chosenImage: $twitterClient.draft.media) +// } } var publishingActions: some View { HStack { - if let replyId = twitterClient.lastTweet?.id { + if twitterClient.lastTweet != nil { Button(action: { if replying { - twitterClient.sendReply(to: replyId) + Task { + await twitterClient.sendTweet(asReply: true) + } } else { withAnimation(.springAnimation) { replying = true } } @@ -48,12 +50,14 @@ struct ActionBarView: View { isLoading: twitterClient.state == .busy && replying ) ) - .disabled(replying && !twitterClient.draft.isValid) + .disabled(replying && !twitterClient.draftIsValid()) } Button(action: { if !replying { - twitterClient.sendTweet() + Task { + await twitterClient.sendTweet() + } } else { withAnimation(.springAnimation) { replying = false } } @@ -74,7 +78,7 @@ struct ActionBarView: View { isLoading: twitterClient.state == .busy && !replying ) ) - .disabled(!replying && !twitterClient.draft.isValid) + .disabled(!replying && !twitterClient.draftIsValid()) .accessibilityIdentifier("sendTweetButton") Button(action: { diff --git a/Broadcast/Helper Views/ComposerView.swift b/Broadcast/Helper Views/ComposerView.swift index cb05aa8..aca2ca4 100644 --- a/Broadcast/Helper Views/ComposerView.swift +++ b/Broadcast/Helper Views/ComposerView.swift @@ -7,6 +7,7 @@ import SwiftUI import TwitterText +import Twift fileprivate let placeholderCandidates: [String] = [ "Wh—what’s going on?", @@ -45,7 +46,7 @@ struct ComposerView: View { TwitterText.tweetLength(text: tweetText) } - private var mentionCandidates: [TwitterClient.User]? { + private var mentionCandidates: [User]? { twitterClient.userSearchResults } @@ -78,17 +79,17 @@ struct ComposerView: View { ZStack(alignment: .bottom) { VStack(alignment: .trailing) { HStack(alignment: .top) { - if let profileImageURL = twitterClient.user?.profileImageURL { - RemoteImage(url: profileImageURL, placeholder: { ProgressView() }) - .aspectRatio(contentMode: .fill) - .frame(width: 36, height: 36) - .cornerRadius(36) - .onTapGesture { - signOutScreenIsPresented = true - UIApplication.shared.endEditing() - } + AsyncImage(url: twitterClient.user?.profileImageUrl) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 36, height: 36) + .cornerRadius(36) + } placeholder: { + ProgressView() + } .accessibilityIdentifier("profilePhotoButton") - } + ZStack(alignment: .topLeading) { Text(tweetText.isEmpty ? placeholder : tweetText) @@ -114,7 +115,7 @@ struct ComposerView: View { Menu { Button(action: { twitterClient.saveDraft() }) { Label("Save Draft", systemImage: "square.and.pencil") - }.disabled(!twitterClient.draft.isValid) + }.disabled(!twitterClient.draftIsValid()) Button(action: { draftListVisible = true }) { Label("View Drafts", systemImage: "doc.on.doc") @@ -139,7 +140,7 @@ struct ComposerView: View { rotatePlaceholder() Haptics.shared.sendStandardFeedback(feedbackType: .success) } - .onChange(of: twitterClient.draft.isValid) { isValid in + .onChange(of: twitterClient.draftIsValid()) { isValid in if !isValid && charCount > 280 { Haptics.shared.sendStandardFeedback(feedbackType: .warning) } @@ -153,7 +154,9 @@ struct ComposerView: View { if let screenName = value { debouncer.renewInterval() debouncer.handler = { - self.twitterClient.searchScreenNames(screenName) + Task { + await self.twitterClient.searchScreenNames(screenName) + } } } } @@ -169,9 +172,9 @@ struct ComposerView: View { }.cornerRadius(captionSize) } - func completeMention(_ user: TwitterClient.User) { + func completeMention(_ user: User) { let textToComplete = mentioningRegex.firstMatchAsString(tweetText) ?? "" - let draft = twitterClient.draft.text?.replacingOccurrences(of: textToComplete, with: "@\(user.screenName) ") + let draft = twitterClient.draft.text?.replacingOccurrences(of: textToComplete, with: "@\(user.username) ") twitterClient.draft.text = draft } diff --git a/Broadcast/Helper Views/EngagementCountersView.swift b/Broadcast/Helper Views/EngagementCountersView.swift index 26594a2..bdb96ec 100644 --- a/Broadcast/Helper Views/EngagementCountersView.swift +++ b/Broadcast/Helper Views/EngagementCountersView.swift @@ -6,12 +6,13 @@ // import SwiftUI +import Twift struct EngagementCountersView: View { - var tweet: TwitterClient.Tweet + var tweet: Tweet var repliesString: String { - let replyCount = tweet.replies?.count ?? 0 + let replyCount = tweet.publicMetrics?.replyCount ?? 0 switch replyCount { case 0: return "No replies" @@ -28,9 +29,3 @@ struct EngagementCountersView: View { .foregroundColor(.accentColor) } } - -struct EngagementCountersView_Previews: PreviewProvider { - static var previews: some View { - EngagementCountersView(tweet: TwitterClient.Tweet(likes: 420, retweets: 69, replies: [])) - } -} diff --git a/Broadcast/Helper Views/LastTweetReplyView.swift b/Broadcast/Helper Views/LastTweetReplyView.swift index 818ccfa..fa5cd96 100644 --- a/Broadcast/Helper Views/LastTweetReplyView.swift +++ b/Broadcast/Helper Views/LastTweetReplyView.swift @@ -6,10 +6,11 @@ // import SwiftUI +import Twift struct LastTweetReplyView: View { @ScaledMetric var spacing: CGFloat = 4 - var lastTweet: TwitterClient.Tweet + var lastTweet: Tweet var body: some View { VStack(alignment: .leading, spacing: spacing) { @@ -41,8 +42,8 @@ struct LastTweetReplyView: View { } } -struct LastTweetReplyView_Previews: PreviewProvider { - static var previews: some View { - LastTweetReplyView(lastTweet: TwitterClient.Tweet(text: "Example tweet")) - } -} +//struct LastTweetReplyView_Previews: PreviewProvider { +// static var previews: some View { +// LastTweetReplyView(lastTweet: TwitterClient.Tweet(text: "Example tweet")) +// } +//} diff --git a/Broadcast/Helper Views/MentionBar.swift b/Broadcast/Helper Views/MentionBar.swift index 2881e87..fb911fd 100644 --- a/Broadcast/Helper Views/MentionBar.swift +++ b/Broadcast/Helper Views/MentionBar.swift @@ -6,10 +6,11 @@ // import SwiftUI +import Twift struct MentionBar: View { - var users: [TwitterClient.User] - var tapHandler: (TwitterClient.User) -> Void = { _ in } + var users: [User] + var tapHandler: (User) -> Void = { _ in } var body: some View { ScrollView(.horizontal) { @@ -29,9 +30,3 @@ struct MentionBar: View { .background(VisualEffectView(effect: UIBlurEffect(style: .prominent))) } } - -struct MentionBar_Previews: PreviewProvider { - static var previews: some View { - MentionBar(users: [.mockUser]) - } -} diff --git a/Broadcast/Helper Views/RepliesListView.swift b/Broadcast/Helper Views/RepliesListView.swift index 03c9b35..e47a966 100644 --- a/Broadcast/Helper Views/RepliesListView.swift +++ b/Broadcast/Helper Views/RepliesListView.swift @@ -6,36 +6,22 @@ // import SwiftUI +import Twift struct RepliesListView: View { @Environment(\.presentationMode) var presentationMode - var tweet: TwitterClient.Tweet? + var tweet: Tweet? + @State var replies: [Tweet] = [] var body: some View { NavigationView { - Group { - if let tweet = tweet, let replies = tweet.replies, !replies.isEmpty { - List { - ForEach(replies, id: \.id) { reply in - TweetView(tweet: reply) - .onTapGesture { - guard let screenName = reply.author?.screenName, - let tweetId = reply.id else { return } - let url = URL(string: "https://twitter.com/\(screenName)/status/\(tweetId)") - - UIApplication.shared.open(url!) - } - } + NullStateView(type: .replies) + .navigationTitle("Replies") + .toolbar { + Button("Close") { + presentationMode.wrappedValue.dismiss() } - } else { - NullStateView(type: .replies) } - }.navigationTitle("Replies") - .toolbar { - Button("Close") { - presentationMode.wrappedValue.dismiss() - } - } } } } diff --git a/Broadcast/Helper Views/TweetView.swift b/Broadcast/Helper Views/TweetView.swift index d7058f2..69a4a09 100644 --- a/Broadcast/Helper Views/TweetView.swift +++ b/Broadcast/Helper Views/TweetView.swift @@ -6,11 +6,13 @@ // import SwiftUI +import Twift struct TweetView: View { @ScaledMetric private var avatarSize: CGFloat = 36 @ScaledMetric private var padding: CGFloat = 4 - var tweet: TwitterClient.Tweet + var tweet: Tweet + var author: User var formatter: RelativeDateTimeFormatter { let formatter = RelativeDateTimeFormatter() @@ -23,19 +25,20 @@ struct TweetView: View { var body: some View { HStack(alignment: .top) { - if let imageUrl = tweet.author?.profileImageURL { - RemoteImage(url: imageUrl, placeholder: { ProgressView() }) + AsyncImage(url: author.profileImageUrl) { image in + image + .resizable() .aspectRatio(contentMode: .fill) .frame(width: avatarSize, height: avatarSize) .cornerRadius(36) + } placeholder: { + ProgressView() } VStack(alignment: .leading, spacing: 4) { HStack { - if let tweetAuthorName = tweet.author?.name, - let screenName = tweet.author?.screenName, - let date = tweet.date { - Text("\(Text(tweetAuthorName).fontWeight(.bold).foregroundColor(.primary)) \(Text("@\(screenName)")) • \(Text(formatter.localizedString(for: date, relativeTo: Date())))") + if let date = tweet.createdAt { + Text("\(Text(author.name).fontWeight(.bold).foregroundColor(.primary)) \(Text("@\(author.username)")) • \(date.formatted(.relative(presentation: .named)))))") .foregroundColor(.secondary) } } @@ -50,9 +53,3 @@ struct TweetView: View { .padding(.vertical, padding) } } - -struct TweetView_Previews: PreviewProvider { - static var previews: some View { - TweetView(tweet: TwitterClient.Tweet.mockTweet) - } -} diff --git a/Broadcast/Helper Views/UserView.swift b/Broadcast/Helper Views/UserView.swift index 70a6693..cc99d29 100644 --- a/Broadcast/Helper Views/UserView.swift +++ b/Broadcast/Helper Views/UserView.swift @@ -6,18 +6,22 @@ // import SwiftUI +import Twift struct UserView: View { @ScaledMetric var avatarSize: CGFloat = 24 - var user: TwitterClient.User + var user: User var body: some View { HStack { - if let imageUrl = user.profileImageURL { - RemoteImage(url: imageUrl, placeholder: { ProgressView() }) + AsyncImage(url: user.profileImageUrl) { image in + image + .resizable() .aspectRatio(contentMode: .fill) .frame(width: avatarSize, height: avatarSize) .cornerRadius(36) + } placeholder: { + ProgressView() } VStack(alignment: .leading) { @@ -26,15 +30,15 @@ struct UserView: View { .fontWeight(.bold) } - Text("@\(user.screenName)") + Text("@\(user.username)") .foregroundColor(.secondary) } }.font(.broadcastFootnote) } } -struct UserView_Previews: PreviewProvider { - static var previews: some View { - UserView(user: .mockUser) - } -} +//struct UserView_Previews: PreviewProvider { +// static var previews: some View { +// UserView(user: .mockUser) +// } +//} diff --git a/Broadcast/Helpers/TwitterClient.swift b/Broadcast/Helpers/TwitterClient.swift index 5688fa2..6d72bd4 100644 --- a/Broadcast/Helpers/TwitterClient.swift +++ b/Broadcast/Helpers/TwitterClient.swift @@ -7,7 +7,7 @@ import Foundation import Combine -import Swifter +import Twift import TwitterText import UIKit import AuthenticationServices @@ -19,65 +19,53 @@ let typeaheadToken = "AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puT class TwitterClient: NSObject, ObservableObject { let draftsStore = PersistanceController.shared @Published var user: User? - @Published var draft = Tweet() + @Published var draft: MutableTweet = .init() @Published var state: State = .idle @Published var lastTweet: Tweet? - - private var client = Swifter.init(consumerKey: ClientCredentials.apiKey, consumerSecret: ClientCredentials.apiSecret) + @Published var client: Twift? override init() { super.init() - if let storedCredentials = retreiveCredentials() { - self.client.client.credential = .init(accessToken: storedCredentials) - if let userId = storedCredentials.userID, - let screenName = storedCredentials.screenName { - self.user = .init(id: userId, screenName: screenName) - self.revalidateAccount() + Task { + if let storedCredentials = self.retreiveCredentials() { + let newClient = await Twift(.userAccessTokens( + clientCredentials: ClientCredentials.credentials, + userCredentials: storedCredentials + )) + + await self.updateClient(newClient) } } } - func signIn() { - DispatchQueue.main.async { self.state = .busy } - client.authorize(withProvider: self, callbackURL: ClientCredentials.callbackURL) { credentials, response in - guard let credentials = credentials, - let id = credentials.userID, - let screenName = credentials.screenName else { - self.state = .error("Yikes, something when wrong when trying to sign in") - return - } - - self.storeCredentials(credentials: credentials) - - DispatchQueue.main.async { - self.state = .idle - self.user = User(id: id, screenName: screenName) - self.revalidateAccount() - } + @MainActor + private func updateClient(_ client: Twift?) async { + self.client = client + + if let client = client { + self.user = try? await client.getMe(fields: [\.profileImageUrl]).data + self.lastTweet = try? await client.userTimeline(fields: [\.createdAt, \.publicMetrics]).data.first } } - func revalidateAccount() { - guard let userId = user?.id else { - self.signOut() - return - } + func signIn() { + DispatchQueue.main.async { self.state = .busy } - client.showUser(.id(userId), tweetMode: .extended) { json in - /** If the `showUser` call was successful, we can reuse the result to update the user’s profile photo */ - guard let urlString = json["profile_image_url_https"].string else { - return - } - - withAnimation { - self.user?.originalProfileImageURL = URL(string: urlString.replacingOccurrences(of: "_normal", with: "")) + Twift.Authentication().requestUserCredentials(clientCredentials: ClientCredentials.credentials, + callbackURL: ClientCredentials.callbackURL) { (userCredentials, error) in + Task.detached { + if let userCredentials = userCredentials { + let newClient = await Twift(.userAccessTokens(clientCredentials: ClientCredentials.credentials, userCredentials: userCredentials)) + await self.updateClient(newClient) + self.storeCredentials(credentials: userCredentials) + } else if let error = error { + print(error) + } + + + self.state = .idle } - - self.updateLastTweet(from: json["status"]) - } failure: { error in - self.signOut() - self.updateState(.error("Yikes; there was a problem signing in to Twitter. You’ll have to try signing in again.")) } } @@ -88,29 +76,28 @@ class TwitterClient: NSObject, ObservableObject { KeychainWrapper.standard.remove(forKey: "broadcast-credentials") } - func storeCredentials(credentials: Credential.OAuthAccessToken) { - guard let data = credentials.data else { + func storeCredentials(credentials: OAuthCredentials) { + guard let data = try? JSONEncoder().encode(credentials) else { return } KeychainWrapper.standard.set(data, forKey: "broadcast-credentials") } - func retreiveCredentials() -> Credential.OAuthAccessToken? { - if isTestEnvironment { - return .init(queryString: ClientCredentials.__authQueryString) - } - + func retreiveCredentials() -> OAuthCredentials? { + // TODO: Fix test environment + // if isTestEnvironment { + // return .init(queryString: ClientCredentials.__authQueryString) + // } guard let data = KeychainWrapper.standard.data(forKey: "broadcast-credentials") else { return nil } - return .init(from: data) + return try? JSONDecoder().decode(OAuthCredentials.self, from: data) } - private func sendTweetCallback(response: JSON? = nil, error: Error? = nil) { - if let json = response { - self.updateLastTweet(from: json) + private func sendTweetCallback(response: TwitterAPIData? = nil, error: Error? = nil) { + if response != nil { self.updateState(.idle) self.draft = .init() Haptics.shared.sendStandardFeedback(feedbackType: .success) @@ -127,58 +114,19 @@ class TwitterClient: NSObject, ObservableObject { } } - func sendTweet() { + func sendTweet(asReply: Bool = false) async { updateState(.busy) - - if let mediaData = draft.media?.jpegData(compressionQuality: 0.8) { - client.postTweet(status: draft.text ?? "", media: mediaData) { json in - self.sendTweetCallback(response: json) - } failure: { error in - print(error.localizedDescription) - self.sendTweetCallback(error: error) - } - } else if let status = draft.text { - client.postTweet(status: status) { json in - self.sendTweetCallback(response: json) - } failure: { error in - print(error.localizedDescription) - self.sendTweetCallback(error: error) - } + if asReply, let lastTweet = lastTweet { + draft.reply = .init(inReplyToTweetId: lastTweet.id) } - } - - func sendReply(to id: String) { - updateState(.busy) - if let mediaData = draft.media?.jpegData(compressionQuality: 0.8) { - client.postTweet(status: draft.text ?? "", media: mediaData, inReplyToStatusID: id) { json in - self.sendTweetCallback(response: json) - } failure: { error in - self.sendTweetCallback(error: error) - } - } else if let status = draft.text { - client.postTweet(status: status, inReplyToStatusID: id) { json in - self.sendTweetCallback(response: json) - Haptics.shared.sendStandardFeedback(feedbackType: .success) - } failure: { error in - self.sendTweetCallback(error: error) - } - } - } - - private func updateLastTweet(from json: JSON) { - guard let id = json["id_str"].string else { return } - var lastTweet = Tweet(id: id) - lastTweet.text = json["full_text"].string ?? json["text"].string - lastTweet.likes = json["favorite_count"].integer - lastTweet.retweets = json["retweet_count"].integer - lastTweet.numericId = json["id"].integer + let result = try? await client?.postTweet(draft) - if !isTestEnvironment { - self.getReplies(for: lastTweet) { replies in - lastTweet.replies = replies - self.lastTweet = lastTweet - } + if let result = result { + self.lastTweet = try? await client?.getTweet(result.data.id).data + sendTweetCallback(response: result, error: nil) + } else { + sendTweetCallback(response: nil, error: nil) } } @@ -191,44 +139,30 @@ class TwitterClient: NSObject, ObservableObject { } @Published var userSearchResults: [User]? - private var userSearchCancellables = [AnyCancellable]() - func searchScreenNames(_ screenName: String) { + func searchScreenNames(_ screenName: String) async { let url = URL(string: "https://twitter.com/i/search/typeahead.json?count=10&q=%23\(screenName)&result_type=users")! var headers = [ "Authorization": typeaheadToken ] - if let userId = user?.id, - let token = client.client.credential?.accessToken?.key { - headers["Cookie"] = "twid=u%3D\(userId);auth_token=\(token)" - } + // TODO: Fix user auth for typeahead search +// if let userId = user?.id, +// let token = ClientCredentials.credentials.key { +// headers["Cookie"] = "twid=u%3D\(userId);auth_token=\(token)" +// } var request = URLRequest(url: url) request.allHTTPHeaderFields = headers request.httpShouldHandleCookies = true - URLSession.shared.dataTaskPublisher(for: request) - .tryMap() { element -> Data in - guard let httpResponse = element.response as? HTTPURLResponse, - httpResponse.statusCode == 200 else { - throw URLError(.badServerResponse) - } - return element.data - } - .decode(type: TypeaheadResponse.self, decoder: JSONDecoder()) - .sink { completion in - switch completion { - case .failure(let error): - print(error.localizedDescription) - default: - return - } - } receiveValue: { result in - DispatchQueue.main.async { - self.userSearchResults = result.users - } - }.store(in: &userSearchCancellables) + do { + let (result, _) = try await URLSession.shared.data(for: request) + let decodedResult = try JSONDecoder().decode(TypeaheadResponse.self, from: result) + self.userSearchResults = decodedResult.users + } catch { + print(error) + } } /// Asynchronously provides up to 200 replies for the given tweet. This method works by fetching the @@ -236,56 +170,43 @@ class TwitterClient: NSObject, ObservableObject { /// - Parameters: /// - tweet: The tweet to fetch replies for /// - completion: A callback for handling the replies - private func getReplies(for tweet: Tweet, completion: @escaping ([Tweet]) -> Void = { _ in }) { - let formatter = DateFormatter() - formatter.dateFormat = "EE MMM dd HH:mm:ss Z yyyy" + private func getReplies(for tweetId: Tweet.ID) async -> [Tweet] { + let mentions = try? await client?.userMentions(fields: [\.authorId, \.publicMetrics, \.createdAt, \.referencedTweets], + expansions: [.authorId(userFields: [\.profileImageUrl])], + sinceId: tweetId, + maxResults: 100) - guard let tweetId = tweet.id else { return } + let repliesToTweet = mentions?.data + .filter { $0.referencedTweets?.contains(where: { $0.id == tweetId }) ?? false } ?? [] + let replyAuthors = mentions?.includes?.users? + .filter { user in repliesToTweet.contains(where: { $0.authorId == user.id }) } ?? [] - client.getMentionsTimelineTweets(count: 200, tweetMode: .extended) { json in - guard let repliesResult = json.array else { return } - let repliesToThisTweet: [Tweet?] = repliesResult.filter { json in - guard let replyId = json["in_reply_to_status_id"].integer else { return false } - return replyId == tweet.numericId - }.map { json in - guard let id = json["id_str"].string, - let text = json["full_text"].string, - let dateString = json["created_at"].string, - let date = formatter.date(from: dateString) else { - return nil - } - - let user = User(from: json["user"]) - return Tweet(id: id, text: text, date: date, author: user) - } - - completion(repliesToThisTweet.compactMap { $0 }) - - } failure: { error in - print("Error fetching replies for Tweet with ID \(tweetId)") - print(error.localizedDescription) - } - } -} - -extension TwitterClient: ASWebAuthenticationPresentationContextProviding { - func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { - return ASPresentationAnchor() + return repliesToTweet } } /* MARK: Drafts */ extension TwitterClient { + public func draftIsValid() -> Bool { + if let text = draft.text, !text.isEmpty { + return TwitterText.remainingCharacterCount(text: text) >= 0 + } else if draft.media != nil { + return true + } else { + return false + } + } /// Saves the current draft to CoreData for later retrieval. This method also resets/clears the current draft. func saveDraft() { - guard draft.isValid else { return } - let copy = draft + guard draftIsValid() else { return } + let copy = draft DispatchQueue.global(qos: .default).async { let newDraft = Draft.init(context: self.draftsStore.context) newDraft.date = Date() newDraft.text = copy.text - newDraft.media = copy.media?.fixedOrientation.pngData() + // TODO: Fix draft media + //newDraft.media = copy.media?.fixedOrientation.pngData() newDraft.id = UUID() self.draftsStore.save() @@ -301,16 +222,17 @@ extension TwitterClient { /// - Parameter draft: The chosen draft for retrieval and deletion func retreiveDraft(draft: Draft) { withAnimation { - self.draft = Tweet(text: draft.text) + self.draft = MutableTweet(text: draft.text) - if let media = draft.media { - self.draft.media = UIImage(data: media) - } + // TODO: Fix draft media +// if let media = draft.media { +// self.draft.media = UIImage(data: media) +// } } let managedObjectContext = PersistanceController.shared.context managedObjectContext.delete(draft) - + PersistanceController.shared.save() } } @@ -350,6 +272,10 @@ extension TwitterClient { return value } + static var credentials: OAuthCredentials { + .init(key: apiKey, secret: apiSecret) + } + static var __authQueryString: String { guard let value = plist?.object(forKey: "__TEST_AUTH_QUERY_STRING") as? String else { fatalError("Couldn't find key '__TEST_AUTH_QUERY_STRING' in 'TwitterAPI-Info.plist'.") @@ -363,100 +289,9 @@ extension TwitterClient { URL(string: callbackProtocol)! } } - - struct User: Decodable { - var id: String - var screenName: String - var name: String? - var originalProfileImageURL: URL? - var profileImageURL: URL? { - if let urlString = originalProfileImageURL?.absoluteString.replacingOccurrences(of: "_normal", with: "_x96") { - return URL(string: urlString) - } else { - return originalProfileImageURL - } - } - - enum CodingKeys: String, CodingKey { - case screenName = "screen_name" - case originalProfileImageURL = "profile_image_url_https" - case id = "id_str" - case name - } - } - - struct Tweet { - var numericId: Int? - var id: String? - var text: String? - var media: UIImage? - - var likes: Int? - var retweets: Int? - var replies: [Tweet]? - - var date: Date? - - var length: Int { - TwitterText.tweetLength(text: text ?? "") - } - - var isValid: Bool { - if media != nil { - return true - } - - return 1...280 ~= length && !(text ?? "").isBlank - } - - var author: User? - } -} - -extension TwitterClient.User { - init(from json: JSON) { - self.name = json["name"].string - self.screenName = json["screen_name"].string ?? "TwitterUser" - self.id = json["id_str"].string ?? "" - let imageUrlString = json["profile_image_url_https"].string ?? "" - self.originalProfileImageURL = URL(string: imageUrlString) - } -} - -extension Credential.OAuthAccessToken { - var data: Data? { - let dict = [ - "key": key, - "secret": secret, - "userId": userID, - "screenName": screenName - ] - - return try? JSONSerialization.data(withJSONObject: dict, options: []) - } - - init?(from data: Data) { - do { - let dict = try JSONDecoder().decode([String: String].self, from: data) - guard let key = dict["key"], - let secret = dict["secret"] else { - return nil - } - - let screenName = dict["screenName"] - let userId = dict["userId"] - - let queryString = "oauth_token=\(key)&oauth_token_secret=\(secret)&screen_name=\(screenName ?? "")&user_id=\(userId ?? "")" - - self.init(queryString: queryString) - } catch let error { - print(error.localizedDescription) - return nil - } - } } struct TypeaheadResponse: Decodable { var num_results: Int - var users: [TwitterClient.User]? + var users: [User]? } diff --git a/Broadcast/SignOutView.swift b/Broadcast/SignOutView.swift index f0555e6..4a4a184 100644 --- a/Broadcast/SignOutView.swift +++ b/Broadcast/SignOutView.swift @@ -28,7 +28,7 @@ struct SignOutView: View { var body: some View { VStack { Spacer() - if let screenName = twitterClient.user?.screenName { + if let screenName = twitterClient.user?.username { Label("Drag to sign out @\(screenName)", systemImage: "arrow.down.circle") .font(.broadcastBody.bold()) .foregroundColor(.secondary) @@ -38,11 +38,12 @@ struct SignOutView: View { VStack { Group { - if let profileImageURL = twitterClient.user?.profileImageURL { - RemoteImage(url: profileImageURL, placeholder: { ProgressView() }) + AsyncImage(url: twitterClient.user?.profileImageUrl) { image in + image + .resizable() .aspectRatio(contentMode: .fill) .clipShape(Circle()) - } else { + } placeholder: { Image(systemName: "person.crop.circle.fill") .resizable() } From 2f7c7e712fa4df87403e2b5640e163ff9759e23f Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Thu, 27 Jan 2022 10:21:15 +0000 Subject: [PATCH 08/36] Fix sign out view --- Broadcast/Helper Views/ComposerView.swift | 3 +++ Broadcast/Helpers/TwitterClient.swift | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Broadcast/Helper Views/ComposerView.swift b/Broadcast/Helper Views/ComposerView.swift index aca2ca4..872eb18 100644 --- a/Broadcast/Helper Views/ComposerView.swift +++ b/Broadcast/Helper Views/ComposerView.swift @@ -88,6 +88,9 @@ struct ComposerView: View { } placeholder: { ProgressView() } + .onTapGesture { + signOutScreenIsPresented = true + } .accessibilityIdentifier("profilePhotoButton") diff --git a/Broadcast/Helpers/TwitterClient.swift b/Broadcast/Helpers/TwitterClient.swift index 6d72bd4..87386ed 100644 --- a/Broadcast/Helpers/TwitterClient.swift +++ b/Broadcast/Helpers/TwitterClient.swift @@ -49,8 +49,9 @@ class TwitterClient: NSObject, ObservableObject { } } + @MainActor func signIn() { - DispatchQueue.main.async { self.state = .busy } + self.state = .busy Twift.Authentication().requestUserCredentials(clientCredentials: ClientCredentials.credentials, callbackURL: ClientCredentials.callbackURL) { (userCredentials, error) in From d5ba7ea9276efb82235f874883ffcbdff2e4de67 Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Thu, 27 Jan 2022 11:57:18 +0000 Subject: [PATCH 09/36] Update version number --- Broadcast.xcodeproj/project.pbxproj | 25 +++---------------- .../xcshareddata/swiftpm/Package.resolved | 9 ------- 2 files changed, 4 insertions(+), 30 deletions(-) diff --git a/Broadcast.xcodeproj/project.pbxproj b/Broadcast.xcodeproj/project.pbxproj index 7188ff1..a929b55 100644 --- a/Broadcast.xcodeproj/project.pbxproj +++ b/Broadcast.xcodeproj/project.pbxproj @@ -11,7 +11,6 @@ 711EF99426C959A700FD8A9F /* BroadcastUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711EF99326C959A700FD8A9F /* BroadcastUITests.swift */; }; 711F3FFA268F50C800605C89 /* Animation.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711F3FF9268F50C800605C89 /* Animation.extension.swift */; }; 714782D5278D97AB00942618 /* Twift in Frameworks */ = {isa = PBXBuildFile; productRef = 714782D4278D97AB00942618 /* Twift */; }; - 715AAE0A26C923A1002BCEA1 /* Swifter in Frameworks */ = {isa = PBXBuildFile; productRef = 715AAE0926C923A1002BCEA1 /* Swifter */; }; 715AAE0C26C9589A002BCEA1 /* TestUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 715AAE0B26C9589A002BCEA1 /* TestUtils.swift */; }; 717041F326B6FFEA00001360 /* RepliesListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 717041F226B6FFEA00001360 /* RepliesListView.swift */; }; 717041F526B7037300001360 /* TweetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 717041F426B7037300001360 /* TweetView.swift */; }; @@ -133,7 +132,6 @@ 7188E66A2688D7BA007CFD78 /* Introspect in Frameworks */, 719087CD26891586005B96CE /* TwitterText in Frameworks */, 714782D5278D97AB00942618 /* Twift in Frameworks */, - 715AAE0A26C923A1002BCEA1 /* Swifter in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -288,7 +286,6 @@ 7188E65C26887DCD007CFD78 /* SwiftKeychainWrapper */, 7188E6692688D7BA007CFD78 /* Introspect */, 719087CC26891586005B96CE /* TwitterText */, - 715AAE0926C923A1002BCEA1 /* Swifter */, 714782D4278D97AB00942618 /* Twift */, ); productName = Broadcast; @@ -326,7 +323,6 @@ 7188E65B26887DCD007CFD78 /* XCRemoteSwiftPackageReference "SwiftKeychainWrapper" */, 7188E6682688D7BA007CFD78 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, 719087CB26891586005B96CE /* XCRemoteSwiftPackageReference "twitter-text" */, - 715AAE0826C923A1002BCEA1 /* XCRemoteSwiftPackageReference "Swifter" */, 714782D3278D97AB00942618 /* XCRemoteSwiftPackageReference "Twift" */, ); productRefGroup = 7188E6262687B0FE007CFD78 /* Products */; @@ -605,7 +601,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 38; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Broadcast/Preview Content\""; DEVELOPMENT_TEAM = YC249PY26F; ENABLE_PREVIEWS = YES; @@ -615,7 +611,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 2.0; PRODUCT_BUNDLE_IDENTIFIER = me.daneden.Broadcast; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -629,7 +625,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 38; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Broadcast/Preview Content\""; DEVELOPMENT_TEAM = YC249PY26F; ENABLE_PREVIEWS = YES; @@ -639,7 +635,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 2.0; PRODUCT_BUNDLE_IDENTIFIER = me.daneden.Broadcast; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -688,14 +684,6 @@ kind = branch; }; }; - 715AAE0826C923A1002BCEA1 /* XCRemoteSwiftPackageReference "Swifter" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/daneden/Swifter"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 2.5.1; - }; - }; 7188E65B26887DCD007CFD78 /* XCRemoteSwiftPackageReference "SwiftKeychainWrapper" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/jrendel/SwiftKeychainWrapper"; @@ -728,11 +716,6 @@ package = 714782D3278D97AB00942618 /* XCRemoteSwiftPackageReference "Twift" */; productName = Twift; }; - 715AAE0926C923A1002BCEA1 /* Swifter */ = { - isa = XCSwiftPackageProductDependency; - package = 715AAE0826C923A1002BCEA1 /* XCRemoteSwiftPackageReference "Swifter" */; - productName = Swifter; - }; 7188E65C26887DCD007CFD78 /* SwiftKeychainWrapper */ = { isa = XCSwiftPackageProductDependency; package = 7188E65B26887DCD007CFD78 /* XCRemoteSwiftPackageReference "SwiftKeychainWrapper" */; diff --git a/Broadcast.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Broadcast.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index aef562c..6132bb4 100644 --- a/Broadcast.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Broadcast.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,15 +1,6 @@ { "object": { "pins": [ - { - "package": "Swifter", - "repositoryURL": "https://github.com/daneden/Swifter", - "state": { - "branch": null, - "revision": "21a1cf736971d07dec56cf5cc0294f31a34ec528", - "version": "2.5.1" - } - }, { "package": "SwiftKeychainWrapper", "repositoryURL": "https://github.com/jrendel/SwiftKeychainWrapper", From 5d4243a495fcbd1ad212415df8f2382767c13409 Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Thu, 27 Jan 2022 12:05:38 +0000 Subject: [PATCH 10/36] Fix initialiser --- Broadcast/ContentView.swift | 8 +++++++ Broadcast/Helpers/TwitterClient.swift | 30 +++++++++++++-------------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/Broadcast/ContentView.swift b/Broadcast/ContentView.swift index 899a541..d433262 100644 --- a/Broadcast/ContentView.swift +++ b/Broadcast/ContentView.swift @@ -115,6 +115,14 @@ struct ContentView: View { .onPreferenceChange(ReplyBoxSizePreferenceKey.self) { newValue in withAnimation(.springAnimation) { replyBoxHeight = newValue + 8 } } + .overlay { + if twitterClient.state == .initializing { + ZStack { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + }.background(.background) + } + } } } } diff --git a/Broadcast/Helpers/TwitterClient.swift b/Broadcast/Helpers/TwitterClient.swift index 87386ed..89cc358 100644 --- a/Broadcast/Helpers/TwitterClient.swift +++ b/Broadcast/Helpers/TwitterClient.swift @@ -16,33 +16,33 @@ import SwiftUI let typeaheadToken = "AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA" -class TwitterClient: NSObject, ObservableObject { +class TwitterClient: ObservableObject { let draftsStore = PersistanceController.shared @Published var user: User? @Published var draft: MutableTweet = .init() - @Published var state: State = .idle + @Published var state: State = .initializing @Published var lastTweet: Tweet? @Published var client: Twift? - override init() { - super.init() - - Task { - if let storedCredentials = self.retreiveCredentials() { - let newClient = await Twift(.userAccessTokens( - clientCredentials: ClientCredentials.credentials, - userCredentials: storedCredentials - )) - + @MainActor + init() { + if let storedCredentials = self.retreiveCredentials() { + let newClient = Twift(.userAccessTokens( + clientCredentials: ClientCredentials.credentials, + userCredentials: storedCredentials + )) + + Task(priority: .userInitiated) { await self.updateClient(newClient) + withAnimation(.springAnimation) { self.state = .idle } } + } else { + withAnimation(.springAnimation) { self.state = .idle } } } @MainActor private func updateClient(_ client: Twift?) async { - self.client = client - if let client = client { self.user = try? await client.getMe(fields: [\.profileImageUrl]).data self.lastTweet = try? await client.userTimeline(fields: [\.createdAt, \.publicMetrics]).data.first @@ -241,7 +241,7 @@ extension TwitterClient { // MARK: Models extension TwitterClient { enum State: Equatable { - case idle, busy + case idle, busy, initializing case error(_: String? = nil) static var genericTextError = State.error("Oh man, something went wrong sending that tweet. It might be too long.") From e8ab36332081cf423e88027c5218d98b5aa70d2f Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Thu, 27 Jan 2022 13:26:28 +0000 Subject: [PATCH 11/36] Fix replies view (I think) --- Broadcast.xcodeproj/project.pbxproj | 8 +++---- .../xcshareddata/swiftpm/Package.resolved | 2 +- Broadcast/BroadcastApp.swift | 2 +- Broadcast/ContentView.swift | 3 ++- Broadcast/Helper Views/ActionBarView.swift | 2 +- Broadcast/Helper Views/ComposerView.swift | 2 +- Broadcast/Helper Views/DraftsListView.swift | 2 +- Broadcast/Helper Views/RepliesListView.swift | 17 ++++++++++++-- ...lient.swift => TwitterClientManager.swift} | 22 ++++++++++--------- Broadcast/SignOutView.swift | 2 +- 10 files changed, 39 insertions(+), 23 deletions(-) rename Broadcast/Helpers/{TwitterClient.swift => TwitterClientManager.swift} (94%) diff --git a/Broadcast.xcodeproj/project.pbxproj b/Broadcast.xcodeproj/project.pbxproj index a929b55..2b323a4 100644 --- a/Broadcast.xcodeproj/project.pbxproj +++ b/Broadcast.xcodeproj/project.pbxproj @@ -49,7 +49,7 @@ 71A1088A268B073B007E1FFB /* Haptics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71A10889268B073B007E1FFB /* Haptics.swift */; }; 71A6A264278C73AD00BF2387 /* TwitterAPI-Info.example.plist in Resources */ = {isa = PBXBuildFile; fileRef = 71A6A263278C73AD00BF2387 /* TwitterAPI-Info.example.plist */; }; 71AA4AC6268A032400B7B577 /* RemoteImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71AA4AC5268A032400B7B577 /* RemoteImage.swift */; }; - 71B8290C268D0AC6002AEE72 /* TwitterClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71B8290B268D0AC6002AEE72 /* TwitterClient.swift */; }; + 71B8290C268D0AC6002AEE72 /* TwitterClientManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71B8290B268D0AC6002AEE72 /* TwitterClientManager.swift */; }; 71B82910268F2195002AEE72 /* LastTweetReplyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71B8290F268F2195002AEE72 /* LastTweetReplyView.swift */; }; 71BBAAE9268CF1BD004048A0 /* ComposerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71BBAAE8268CF1BD004048A0 /* ComposerView.swift */; }; 71BBAAEB268CF532004048A0 /* TwitterAPI-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 71BBAAEA268CF532004048A0 /* TwitterAPI-Info.plist */; }; @@ -109,7 +109,7 @@ 71A10889268B073B007E1FFB /* Haptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Haptics.swift; sourceTree = ""; }; 71A6A263278C73AD00BF2387 /* TwitterAPI-Info.example.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "TwitterAPI-Info.example.plist"; sourceTree = ""; }; 71AA4AC5268A032400B7B577 /* RemoteImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImage.swift; sourceTree = ""; }; - 71B8290B268D0AC6002AEE72 /* TwitterClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwitterClient.swift; sourceTree = ""; }; + 71B8290B268D0AC6002AEE72 /* TwitterClientManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwitterClientManager.swift; sourceTree = ""; }; 71B8290F268F2195002AEE72 /* LastTweetReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastTweetReplyView.swift; sourceTree = ""; }; 71BBAAE8268CF1BD004048A0 /* ComposerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerView.swift; sourceTree = ""; }; 71BBAAEA268CF532004048A0 /* TwitterAPI-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "TwitterAPI-Info.plist"; sourceTree = ""; }; @@ -216,7 +216,7 @@ 71A10889268B073B007E1FFB /* Haptics.swift */, 71E36FFA2689EED40078D956 /* ShakeModifier.swift */, 7188E6642688A436007CFD78 /* ThemeHelper.swift */, - 71B8290B268D0AC6002AEE72 /* TwitterClient.swift */, + 71B8290B268D0AC6002AEE72 /* TwitterClientManager.swift */, 71800BFE26999BA6009D11A1 /* PersistanceController.swift */, 7199AE7F26B9892E001DEB46 /* Debouncer.swift */, 715AAE0B26C9589A002BCEA1 /* TestUtils.swift */, @@ -395,7 +395,7 @@ 7188E63B2687B19D007CFD78 /* Notification.extension.swift in Sources */, 717041F326B6FFEA00001360 /* RepliesListView.swift in Sources */, 7188E6292687B0FE007CFD78 /* BroadcastApp.swift in Sources */, - 71B8290C268D0AC6002AEE72 /* TwitterClient.swift in Sources */, + 71B8290C268D0AC6002AEE72 /* TwitterClientManager.swift in Sources */, 7188E6632688A0FC007CFD78 /* WelcomeView.swift in Sources */, 7199AE7A26B96D0D001DEB46 /* NSRegularExpression+Convenience.swift in Sources */, 717041F726B703A600001360 /* TwitterClient+MockTweet.swift in Sources */, diff --git a/Broadcast.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Broadcast.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6132bb4..bdd7ab7 100644 --- a/Broadcast.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Broadcast.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -24,7 +24,7 @@ "repositoryURL": "https://github.com/daneden/Twift.git", "state": { "branch": "main", - "revision": "b995245d41c600d107759f292e6ef37688734ef4", + "revision": "ef90bf5165f5d046908faddfe20ae6ecdfcacf79", "version": null } }, diff --git a/Broadcast/BroadcastApp.swift b/Broadcast/BroadcastApp.swift index 508629d..8be644b 100644 --- a/Broadcast/BroadcastApp.swift +++ b/Broadcast/BroadcastApp.swift @@ -11,7 +11,7 @@ import SwiftUI struct BroadcastApp: App { @Environment(\.scenePhase) var scenePhase @StateObject var themeHelper = ThemeHelper.shared - @StateObject var twitterClient = TwitterClient() + @StateObject var twitterClient = TwitterClientManager() let persistenceController = PersistanceController.shared var body: some Scene { diff --git a/Broadcast/ContentView.swift b/Broadcast/ContentView.swift index d433262..2a21c94 100644 --- a/Broadcast/ContentView.swift +++ b/Broadcast/ContentView.swift @@ -15,7 +15,7 @@ struct ContentView: View { @ScaledMetric private var bottomPadding: CGFloat = 80 @ScaledMetric private var replyBoxLimit: CGFloat = 96 - @EnvironmentObject var twitterClient: TwitterClient + @EnvironmentObject var twitterClient: TwitterClientManager @State private var photoPickerIsPresented = false @State private var signOutScreenIsPresented = false @@ -108,6 +108,7 @@ struct ContentView: View { RepliesListView(tweet: twitterClient.lastTweet) .accentColor(ThemeHelper.shared.color) .font(.broadcastBody) + .environmentObject(twitterClient) } .onAppear { UITextView.appearance().backgroundColor = .clear diff --git a/Broadcast/Helper Views/ActionBarView.swift b/Broadcast/Helper Views/ActionBarView.swift index 10083ea..fe5e4f0 100644 --- a/Broadcast/Helper Views/ActionBarView.swift +++ b/Broadcast/Helper Views/ActionBarView.swift @@ -9,7 +9,7 @@ import SwiftUI struct ActionBarView: View { @ScaledMetric var barHeight: CGFloat = 80 - @EnvironmentObject var twitterClient: TwitterClient + @EnvironmentObject var twitterClient: TwitterClientManager @Binding var replying: Bool @State private var photoPickerIsPresented = false diff --git a/Broadcast/Helper Views/ComposerView.swift b/Broadcast/Helper Views/ComposerView.swift index 872eb18..0d94205 100644 --- a/Broadcast/Helper Views/ComposerView.swift +++ b/Broadcast/Helper Views/ComposerView.swift @@ -23,7 +23,7 @@ struct ComposerView: View { let debouncer = Debouncer(timeInterval: 0.3) @Binding var signOutScreenIsPresented: Bool - @EnvironmentObject var twitterClient: TwitterClient + @EnvironmentObject var twitterClient: TwitterClientManager @ScaledMetric private var minComposerHeight: CGFloat = 120 @ScaledMetric private var captionSize: CGFloat = 14 @ScaledMetric private var leftOffset: CGFloat = 4 diff --git a/Broadcast/Helper Views/DraftsListView.swift b/Broadcast/Helper Views/DraftsListView.swift index bd75f7d..fc8359b 100644 --- a/Broadcast/Helper Views/DraftsListView.swift +++ b/Broadcast/Helper Views/DraftsListView.swift @@ -15,7 +15,7 @@ struct DraftsListView: View { @FetchRequest(entity: Draft.entity(), sortDescriptors: [NSSortDescriptor(keyPath: \Draft.date, ascending: true)]) var drafts: FetchedResults - @EnvironmentObject var twitterClient: TwitterClient + @EnvironmentObject var twitterClient: TwitterClientManager @EnvironmentObject var themeHelper: ThemeHelper var body: some View { diff --git a/Broadcast/Helper Views/RepliesListView.swift b/Broadcast/Helper Views/RepliesListView.swift index e47a966..727f438 100644 --- a/Broadcast/Helper Views/RepliesListView.swift +++ b/Broadcast/Helper Views/RepliesListView.swift @@ -9,19 +9,32 @@ import SwiftUI import Twift struct RepliesListView: View { + @EnvironmentObject var twitterClient: TwitterClientManager @Environment(\.presentationMode) var presentationMode var tweet: Tweet? - @State var replies: [Tweet] = [] + @State var replies: [(tweet: Tweet, author: User)] = [] var body: some View { NavigationView { - NullStateView(type: .replies) + Group { + if !replies.isEmpty { + List(replies, id: \.tweet.id) { reply in + TweetView(tweet: reply.tweet, author: reply.author) + } + } else { + NullStateView(type: .replies) + } + } .navigationTitle("Replies") .toolbar { Button("Close") { presentationMode.wrappedValue.dismiss() } } + }.task { + if let tweet = tweet { + replies = await twitterClient.getReplies(for: tweet.id) + } } } } diff --git a/Broadcast/Helpers/TwitterClient.swift b/Broadcast/Helpers/TwitterClientManager.swift similarity index 94% rename from Broadcast/Helpers/TwitterClient.swift rename to Broadcast/Helpers/TwitterClientManager.swift index 89cc358..9072373 100644 --- a/Broadcast/Helpers/TwitterClient.swift +++ b/Broadcast/Helpers/TwitterClientManager.swift @@ -1,5 +1,5 @@ // -// TwitterClient.swift +// TwitterClientManager.swift // Broadcast // // Created by Daniel Eden on 30/06/2021. @@ -16,7 +16,7 @@ import SwiftUI let typeaheadToken = "AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA" -class TwitterClient: ObservableObject { +class TwitterClientManager: ObservableObject { let draftsStore = PersistanceController.shared @Published var user: User? @Published var draft: MutableTweet = .init() @@ -70,6 +70,7 @@ class TwitterClient: ObservableObject { } } + @MainActor func signOut() { self.user = nil self.draft = .init() @@ -148,10 +149,9 @@ class TwitterClient: ObservableObject { ] // TODO: Fix user auth for typeahead search -// if let userId = user?.id, -// let token = ClientCredentials.credentials.key { -// headers["Cookie"] = "twid=u%3D\(userId);auth_token=\(token)" -// } + if let userId = user?.id { + headers["Cookie"] = "twid=u%3D\(userId);auth_token=\(ClientCredentials.credentials.key)" + } var request = URLRequest(url: url) request.allHTTPHeaderFields = headers @@ -171,7 +171,7 @@ class TwitterClient: ObservableObject { /// - Parameters: /// - tweet: The tweet to fetch replies for /// - completion: A callback for handling the replies - private func getReplies(for tweetId: Tweet.ID) async -> [Tweet] { + public func getReplies(for tweetId: Tweet.ID) async -> [(tweet: Tweet, author: User)] { let mentions = try? await client?.userMentions(fields: [\.authorId, \.publicMetrics, \.createdAt, \.referencedTweets], expansions: [.authorId(userFields: [\.profileImageUrl])], sinceId: tweetId, @@ -182,12 +182,14 @@ class TwitterClient: ObservableObject { let replyAuthors = mentions?.includes?.users? .filter { user in repliesToTweet.contains(where: { $0.authorId == user.id }) } ?? [] - return repliesToTweet + return repliesToTweet.map { tweet in + (tweet: tweet, author: replyAuthors.first(where: { $0.id == tweet.authorId! })!) + } } } /* MARK: Drafts */ -extension TwitterClient { +extension TwitterClientManager { public func draftIsValid() -> Bool { if let text = draft.text, !text.isEmpty { return TwitterText.remainingCharacterCount(text: text) >= 0 @@ -239,7 +241,7 @@ extension TwitterClient { } // MARK: Models -extension TwitterClient { +extension TwitterClientManager { enum State: Equatable { case idle, busy, initializing case error(_: String? = nil) diff --git a/Broadcast/SignOutView.swift b/Broadcast/SignOutView.swift index 4a4a184..9c64411 100644 --- a/Broadcast/SignOutView.swift +++ b/Broadcast/SignOutView.swift @@ -11,7 +11,7 @@ import CoreHaptics struct SignOutView: View { @Environment(\.colorScheme) var colorScheme @Environment(\.presentationMode) var presentationMode - @EnvironmentObject var twitterClient: TwitterClient + @EnvironmentObject var twitterClient: TwitterClientManager @EnvironmentObject var themeHelper: ThemeHelper @State private var offset = CGSize.zero From 816783f50fa903c19601f0803154528a88946756 Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Fri, 28 Jan 2022 09:14:08 +0000 Subject: [PATCH 12/36] Add alt text support and multiple media --- Broadcast/ContentView.swift | 5 +- Broadcast/Helper Views/ActionBarView.swift | 28 ++++- .../Helper Views/AttachmentThumbnail.swift | 113 ++++++++++++++---- .../Helper Views/BroadcastButtonStyle.swift | 32 ++++- Broadcast/Helper Views/PhotoPicker.swift | 98 +++++++++++++-- Broadcast/Helper Views/UserView.swift | 10 +- Broadcast/Helpers/TwitterClientManager.swift | 32 ++++- 7 files changed, 269 insertions(+), 49 deletions(-) diff --git a/Broadcast/ContentView.swift b/Broadcast/ContentView.swift index 2a21c94..4ef47eb 100644 --- a/Broadcast/ContentView.swift +++ b/Broadcast/ContentView.swift @@ -70,7 +70,7 @@ struct ContentView: View { alignment: .topLeading ) -// AttachmentThumbnail(image: $twitterClient.draft.media) + AttachmentThumbnail(media: $twitterClient.selectedMedia) } else { WelcomeView() } @@ -88,12 +88,11 @@ struct ContentView: View { Label("Sign In With Twitter", image: "twitter.fill") .font(.broadcastHeadline) } - .buttonStyle(BroadcastButtonStyle()) + .buttonStyle(.bordered) .accessibilityIdentifier("loginButton") } } .padding() - .animation(.springAnimation) .background( VisualEffectView(effect: UIBlurEffect(style: .regular)) .ignoresSafeArea() diff --git a/Broadcast/Helper Views/ActionBarView.swift b/Broadcast/Helper Views/ActionBarView.swift index fe5e4f0..7d5e70a 100644 --- a/Broadcast/Helper Views/ActionBarView.swift +++ b/Broadcast/Helper Views/ActionBarView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import PhotosUI struct ActionBarView: View { @ScaledMetric var barHeight: CGFloat = 80 @@ -14,12 +15,32 @@ struct ActionBarView: View { @State private var photoPickerIsPresented = false + private var pickerConfig: PHPickerConfiguration { + var config = PHPickerConfiguration(photoLibrary: .shared()) + + if moreMediaAllowed && !twitterClient.selectedMedia.isEmpty { + config.filter = .images + config.selectionLimit = 4 - twitterClient.selectedMedia.count + } else { + config.filter = .any(of: [.images, .videos]) + } + + return config + } + + private var moreMediaAllowed: Bool { + if twitterClient.selectedMedia.contains(where: { $0.mimeType?.contains("video") ?? true }) { return false } + if twitterClient.selectedMedia.contains(where: { $0.mimeType?.contains("gif") ?? true }) { return false } + if twitterClient.selectedMedia.count == 4 { return false } + return true + } + var body: some View { publishingActions .disabled(twitterClient.state == .busy) -// .sheet(isPresented: $photoPickerIsPresented) { -// ImagePicker(chosenImage: $twitterClient.draft.media) -// } + .sheet(isPresented: $photoPickerIsPresented) { + ImagePicker(configuration: pickerConfig, selection: $twitterClient.selectedMedia) + } } var publishingActions: some View { @@ -90,6 +111,7 @@ struct ActionBarView: View { } .buttonStyle(BroadcastButtonStyle(prominence: .tertiary, isFullWidth: false)) .accessibilityIdentifier("imagePickerButton") + .disabled(!moreMediaAllowed) } } } diff --git a/Broadcast/Helper Views/AttachmentThumbnail.swift b/Broadcast/Helper Views/AttachmentThumbnail.swift index b529ae0..4f0faa2 100644 --- a/Broadcast/Helper Views/AttachmentThumbnail.swift +++ b/Broadcast/Helper Views/AttachmentThumbnail.swift @@ -8,40 +8,109 @@ import SwiftUI struct AttachmentThumbnail: View { - @Binding var image: UIImage? + @Binding var media: [UserSelectedMedia] + @State private var altTextSheetIsPresented = false var body: some View { - Group { - if let image = image { - ZStack(alignment: .topTrailing) { - Image(uiImage: image) - .resizable() - .aspectRatio(image.size, contentMode: .fill) - .clipShape(RoundedRectangle(cornerRadius: 8)) - - Button(action: removeImage) { - Label("Remove Image", systemImage: "xmark.circle") - .labelStyle(IconOnlyLabelStyle()) - .font(.broadcastTitle.bold()) - .foregroundColor(.white) - .shadow(color: .black, radius: 8, x: 0, y: 4) + VStack { + if let media = media, !media.isEmpty { + ForEach(media, id: \.id) { item in + if let previewData = item.thumbnailData, + let image = UIImage(data: previewData) { + ZStack(alignment: .top) { + Image(uiImage: image) + .resizable() + .aspectRatio(image.size, contentMode: .fill) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + HStack { + if item.canAddAltText { + Button(action: { altTextSheetIsPresented = true }) { + Label("Edit Alt Text", systemImage: "captions.bubble") + .labelStyle(.iconOnly) + } + .buttonStyle(BroadcastButtonStyle(paddingSize: 8, prominence: item.hasAltText ? .primary : .tertiary, isFullWidth: false)) + .clipShape(Circle()) + .offset(x: 8, y: 8) + .sheet(isPresented: $altTextSheetIsPresented) { + AltTextSheet(mediaSet: $media, itemId: item.id) + } + } + + Spacer() + + Button(action: { removeImage(item.id) }) { + Label("Remove Image", systemImage: "xmark") + .labelStyle(.iconOnly) + } + .buttonStyle(BroadcastButtonStyle(paddingSize: 8, prominence: .tertiary, isFullWidth: false)) + .clipShape(Circle()) + .offset(x: -8, y: 8) + } + } } - .buttonStyle(BroadcastButtonStyle(paddingSize: -2, prominence: .tertiary, isFullWidth: false)) - .clipShape(Circle()) - .offset(x: -8, y: 8) } } - }.transition(.opacity) + } + .transition(.opacity) + } + func removeImage(_ id: UUID) { + withAnimation { + media.removeAll(where: { $0.id == id }) + } } +} - func removeImage() { - withAnimation { image = nil } +fileprivate struct AltTextSheet: View { + @Environment(\.presentationMode) var presentationMode + @Binding var mediaSet: [UserSelectedMedia] + var itemId: UUID + + var preview: UIImage? { + guard let data = mediaSet.first(where: { $0.id == itemId })?.thumbnailData, + let image = UIImage(data: data) else { + return nil + } + + return image + } + + var body: some View { + NavigationView { + Form { + if let preview = preview { + HStack { + Spacer() + Image(uiImage: preview) + .resizable() + .aspectRatio(contentMode: .fit) + .cornerRadius(8) + .frame(maxWidth: 200, maxHeight: 200) + Spacer() + } + .padding() + .listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0)) + .listRowBackground(Color.clear) + } + + Section(footer: Text("You can add a description, sometimes called alt-text, to your photos so they’re accessible to even more people, including people who are blind or have low vision. Good descriptions are concise, but present what’s in your photos accurately enough to understand their context.")) { + TextField("Enter Alt Text", text: $mediaSet.first(where: { $0.wrappedValue.id == itemId })!.altText) + } + } + .navigationTitle("Edit Alt Text") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + Button("Close") { + presentationMode.wrappedValue.dismiss() + } + } + } } } struct ThumbnailFilmstrip_Previews: PreviewProvider { static var previews: some View { - AttachmentThumbnail(image: .constant(nil)) + AttachmentThumbnail(media: .constant([])) } } diff --git a/Broadcast/Helper Views/BroadcastButtonStyle.swift b/Broadcast/Helper Views/BroadcastButtonStyle.swift index 79617e5..cb2146a 100644 --- a/Broadcast/Helper Views/BroadcastButtonStyle.swift +++ b/Broadcast/Helper Views/BroadcastButtonStyle.swift @@ -37,15 +37,28 @@ struct BroadcastButtonStyle: ButtonStyle { var isFullWidth = true var isLoading = false + var foregroundStyle: HierarchicalShapeStyle { + switch prominence { + case .primary: + return .primary + case .secondary: + return .secondary + case .tertiary: + return .tertiary + case .destructive: + return .primary + } + } + var background: some View { Group { switch prominence { case .primary: Color.accentColor case .secondary: - Color.accentColor.opacity(0.1) + Color.accentColor.opacity(0.1).background(.ultraThinMaterial) case .tertiary: - VisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterial)) + Color.clear.background(.ultraThinMaterial) case .destructive: Color(.systemRed) } @@ -57,12 +70,25 @@ struct BroadcastButtonStyle: ButtonStyle { case .secondary: return .accentColor case .tertiary: - return .primary + return .secondary default: return .white } } + var prominenceStyle: HierarchicalShapeStyle { + switch prominence { + case .primary: + return .primary + case .secondary: + return .secondary + case .tertiary: + return .tertiary + case .destructive: + return .primary + } + } + func makeBody(configuration: Configuration) -> some View { HStack { if isFullWidth { Spacer(minLength: 0) } diff --git a/Broadcast/Helper Views/PhotoPicker.swift b/Broadcast/Helper Views/PhotoPicker.swift index 4c3a680..5933fd2 100644 --- a/Broadcast/Helper Views/PhotoPicker.swift +++ b/Broadcast/Helper Views/PhotoPicker.swift @@ -6,20 +6,34 @@ // import Foundation -import UIKit +import PhotosUI import SwiftUI +struct UserSelectedMedia { + let id = UUID() + var data: Data? + var thumbnailData: Data? + var mimeType: String? + var altText: String = "" + + var hasAltText: Bool { !altText.isEmpty } + var canAddAltText: Bool { mimeType?.contains("image") ?? false } +} + struct ImagePicker: UIViewControllerRepresentable { @Environment(\.presentationMode) var presentationMode - @Binding var chosenImage: UIImage? + var configuration: PHPickerConfiguration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared()) - func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIImagePickerController { - let picker = UIImagePickerController() - picker.delegate = context.coordinator - return picker + @Binding var selection: [UserSelectedMedia] + + func makeUIViewController(context: UIViewControllerRepresentableContext) -> PHPickerViewController { + let controller = PHPickerViewController(configuration: configuration) + controller.delegate = context.coordinator + + return controller } - func updateUIViewController(_ uiViewController: UIImagePickerController, context: UIViewControllerRepresentableContext) { + func updateUIViewController(_ uiViewController: PHPickerViewController, context: UIViewControllerRepresentableContext) { } @@ -27,19 +41,79 @@ struct ImagePicker: UIViewControllerRepresentable { Coordinator(self) } - class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { + class Coordinator: PHPickerViewControllerDelegate { let parent: ImagePicker init(_ parent: ImagePicker) { self.parent = parent } - func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { - if let image = info[.originalImage] as? UIImage { - parent.chosenImage = image + func picker(_: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + if results.isEmpty { + self.parent.presentationMode.wrappedValue.dismiss() } + let dispatchQueue = DispatchQueue(label: "me.daneden.Twift_SwiftUI.AlbumImageQueue") + var selectedImageDatas = [UserSelectedMedia?](repeating: nil, count: results.count) // Awkwardly named, sure + var totalConversionsCompleted = 0 - parent.presentationMode.wrappedValue.dismiss() + for (index, result) in results.enumerated() { + result.itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.image.identifier) { url, error in + guard let url = url, let rawImageData = try? Data(contentsOf: url) else { + dispatchQueue.sync { totalConversionsCompleted += 1 } + return + } + + let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary + + guard let source = CGImageSourceCreateWithURL(url as CFURL, sourceOptions) else { + dispatchQueue.sync { totalConversionsCompleted += 1 } + return + } + + let downsampleOptions = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: false, + kCGImageSourceThumbnailMaxPixelSize: 2_000, + ] as CFDictionary + + guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions) else { + dispatchQueue.sync { totalConversionsCompleted += 1} + return + } + + let data = NSMutableData() + let utType = UTType.init(filenameExtension: url.pathExtension) + + guard let imageDestination = CGImageDestinationCreateWithData(data, (utType ?? UTType.jpeg).identifier as CFString, 1, nil) else { + dispatchQueue.sync { totalConversionsCompleted += 1 } + return + } + + let destinationProperties = [ + kCGImageDestinationLossyCompressionQuality: utType == .png ? 1.0 : 0.75 + ] as CFDictionary + + CGImageDestinationAddImage(imageDestination, cgImage, destinationProperties) + CGImageDestinationFinalize(imageDestination) + + dispatchQueue.sync { + let selection = UserSelectedMedia(data: rawImageData, + thumbnailData: data as Data, + mimeType: utType?.preferredMIMEType ?? "image/jpeg") + selectedImageDatas[index] = selection + totalConversionsCompleted += 1 + + if totalConversionsCompleted == results.count { + print(selectedImageDatas) + + DispatchQueue.main.async { + self.parent.selection.append(contentsOf: selectedImageDatas.compactMap { $0 }) + } + self.parent.presentationMode.wrappedValue.dismiss() + } + } + } + } } } } diff --git a/Broadcast/Helper Views/UserView.swift b/Broadcast/Helper Views/UserView.swift index cc99d29..4c37ee9 100644 --- a/Broadcast/Helper Views/UserView.swift +++ b/Broadcast/Helper Views/UserView.swift @@ -37,8 +37,8 @@ struct UserView: View { } } -//struct UserView_Previews: PreviewProvider { -// static var previews: some View { -// UserView(user: .mockUser) -// } -//} +struct UserView_Previews: PreviewProvider { + static var previews: some View { + UserView(user: .mockUser) + } +} diff --git a/Broadcast/Helpers/TwitterClientManager.swift b/Broadcast/Helpers/TwitterClientManager.swift index 9072373..5f41a28 100644 --- a/Broadcast/Helpers/TwitterClientManager.swift +++ b/Broadcast/Helpers/TwitterClientManager.swift @@ -23,6 +23,7 @@ class TwitterClientManager: ObservableObject { @Published var state: State = .initializing @Published var lastTweet: Tweet? @Published var client: Twift? + @Published var selectedMedia: [UserSelectedMedia] = [] @MainActor init() { @@ -122,6 +123,35 @@ class TwitterClientManager: ObservableObject { draft.reply = .init(inReplyToTweetId: lastTweet.id) } + var mediaStrings: [String] = [] + for media in selectedMedia { + if let data = media.data, + let mimeTypeString = media.mimeType, + let mimeType = Media.MimeType(rawValue: mimeTypeString) { + let result = try? await client?.upload(mediaData: data, mimeType: mimeType) + + guard let result = result else { + self.state = .genericTextAndMediaError + print(result?.processingInfo?.error) + return + } + + if media.hasAltText { + try? await client?.addAltText(to: result.mediaIdString, text: media.altText) + } + + if result.processingInfo?.state != .failed && result.processingInfo?.state != .succeeded { + _ = try? await client?.checkMediaUploadSuccessful(result.mediaIdString) + } + + mediaStrings.append(result.mediaIdString) + } + } + + if !mediaStrings.isEmpty { + draft.media = MutableMedia(mediaIds: mediaStrings) + } + let result = try? await client?.postTweet(draft) if let result = result { @@ -193,7 +223,7 @@ extension TwitterClientManager { public func draftIsValid() -> Bool { if let text = draft.text, !text.isEmpty { return TwitterText.remainingCharacterCount(text: text) >= 0 - } else if draft.media != nil { + } else if !selectedMedia.isEmpty { return true } else { return false From 968ae02656b425bd135d6d7e38bab6c965a522d7 Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Fri, 28 Jan 2022 11:49:19 +0000 Subject: [PATCH 13/36] Fix client initialisation lol --- .../xcdebugger/Breakpoints_v2.xcbkptlist | 118 ++++++++++++++---- Broadcast/ContentView.swift | 5 +- Broadcast/Helper Views/ActionBarView.swift | 3 +- .../Helper Views/AttachmentThumbnail.swift | 30 +++-- .../Helper Views/BroadcastButtonStyle.swift | 15 +-- Broadcast/Helper Views/PhotoPicker.swift | 5 +- Broadcast/Helpers/TwitterClientManager.swift | 97 +++++++------- 7 files changed, 176 insertions(+), 97 deletions(-) diff --git a/Broadcast.xcodeproj/xcuserdata/dte.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/Broadcast.xcodeproj/xcuserdata/dte.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist index fa8e481..de17bb1 100644 --- a/Broadcast.xcodeproj/xcuserdata/dte.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +++ b/Broadcast.xcodeproj/xcuserdata/dte.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -7,33 +7,109 @@ - - - - + + + + + + + + + + + + + + diff --git a/Broadcast/ContentView.swift b/Broadcast/ContentView.swift index 4ef47eb..7441f58 100644 --- a/Broadcast/ContentView.swift +++ b/Broadcast/ContentView.swift @@ -26,7 +26,7 @@ struct ContentView: View { @State private var replyBoxHeight: CGFloat = 0 private var imageHeightCompensation: CGFloat { - (twitterClient.draft.media == nil ? 0 : bottomPadding) + + (twitterClient.selectedMedia.isEmpty ? 0 : bottomPadding) + (replying ? min(replyBoxHeight, replyBoxLimit) : 0) } @@ -69,6 +69,7 @@ struct ContentView: View { height: geom.size.height - (bottomPadding + (captionSize * 2)) - imageHeightCompensation, alignment: .topLeading ) + .animation(.springAnimation, value: imageHeightCompensation) AttachmentThumbnail(media: $twitterClient.selectedMedia) } else { @@ -88,7 +89,7 @@ struct ContentView: View { Label("Sign In With Twitter", image: "twitter.fill") .font(.broadcastHeadline) } - .buttonStyle(.bordered) + .buttonStyle(BroadcastButtonStyle()) .accessibilityIdentifier("loginButton") } } diff --git a/Broadcast/Helper Views/ActionBarView.swift b/Broadcast/Helper Views/ActionBarView.swift index 7d5e70a..af8a640 100644 --- a/Broadcast/Helper Views/ActionBarView.swift +++ b/Broadcast/Helper Views/ActionBarView.swift @@ -20,7 +20,8 @@ struct ActionBarView: View { if moreMediaAllowed && !twitterClient.selectedMedia.isEmpty { config.filter = .images - config.selectionLimit = 4 - twitterClient.selectedMedia.count + config.selectionLimit = 4 + config.preselectedAssetIdentifiers = twitterClient.selectedMedia.map(\.id) } else { config.filter = .any(of: [.images, .videos]) } diff --git a/Broadcast/Helper Views/AttachmentThumbnail.swift b/Broadcast/Helper Views/AttachmentThumbnail.swift index 4f0faa2..30d2db4 100644 --- a/Broadcast/Helper Views/AttachmentThumbnail.swift +++ b/Broadcast/Helper Views/AttachmentThumbnail.swift @@ -7,9 +7,14 @@ import SwiftUI +extension String: Identifiable { + public var id: String { self } +} + struct AttachmentThumbnail: View { @Binding var media: [UserSelectedMedia] @State private var altTextSheetIsPresented = false + @State private var selectedMediaId: String? var body: some View { VStack { @@ -18,22 +23,22 @@ struct AttachmentThumbnail: View { if let previewData = item.thumbnailData, let image = UIImage(data: previewData) { ZStack(alignment: .top) { - Image(uiImage: image) + Image(uiImage: image.fixedOrientation) .resizable() .aspectRatio(image.size, contentMode: .fill) - .clipShape(RoundedRectangle(cornerRadius: 8)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) HStack { if item.canAddAltText { - Button(action: { altTextSheetIsPresented = true }) { + Button(action: { selectedMediaId = item.id }) { Label("Edit Alt Text", systemImage: "captions.bubble") .labelStyle(.iconOnly) } .buttonStyle(BroadcastButtonStyle(paddingSize: 8, prominence: item.hasAltText ? .primary : .tertiary, isFullWidth: false)) .clipShape(Circle()) .offset(x: 8, y: 8) - .sheet(isPresented: $altTextSheetIsPresented) { - AltTextSheet(mediaSet: $media, itemId: item.id) + .sheet(item: $selectedMediaId) { id in + AltTextSheet(mediaId: id) } } @@ -55,7 +60,7 @@ struct AttachmentThumbnail: View { .transition(.opacity) } - func removeImage(_ id: UUID) { + func removeImage(_ id: String) { withAnimation { media.removeAll(where: { $0.id == id }) } @@ -63,12 +68,17 @@ struct AttachmentThumbnail: View { } fileprivate struct AltTextSheet: View { + @EnvironmentObject var twitterClient: TwitterClientManager + @Environment(\.presentationMode) var presentationMode - @Binding var mediaSet: [UserSelectedMedia] - var itemId: UUID + var mediaId: String + + var media: Binding { + $twitterClient.selectedMedia.first(where: { $0.wrappedValue.id == mediaId })! + } var preview: UIImage? { - guard let data = mediaSet.first(where: { $0.id == itemId })?.thumbnailData, + guard let data = media.wrappedValue.thumbnailData, let image = UIImage(data: data) else { return nil } @@ -95,7 +105,7 @@ fileprivate struct AltTextSheet: View { } Section(footer: Text("You can add a description, sometimes called alt-text, to your photos so they’re accessible to even more people, including people who are blind or have low vision. Good descriptions are concise, but present what’s in your photos accurately enough to understand their context.")) { - TextField("Enter Alt Text", text: $mediaSet.first(where: { $0.wrappedValue.id == itemId })!.altText) + TextField("Enter Alt Text", text: media.altText) } } .navigationTitle("Edit Alt Text") diff --git a/Broadcast/Helper Views/BroadcastButtonStyle.swift b/Broadcast/Helper Views/BroadcastButtonStyle.swift index cb2146a..65094b0 100644 --- a/Broadcast/Helper Views/BroadcastButtonStyle.swift +++ b/Broadcast/Helper Views/BroadcastButtonStyle.swift @@ -76,19 +76,6 @@ struct BroadcastButtonStyle: ButtonStyle { } } - var prominenceStyle: HierarchicalShapeStyle { - switch prominence { - case .primary: - return .primary - case .secondary: - return .secondary - case .tertiary: - return .tertiary - case .destructive: - return .primary - } - } - func makeBody(configuration: Configuration) -> some View { HStack { if isFullWidth { Spacer(minLength: 0) } @@ -110,7 +97,7 @@ struct BroadcastButtonStyle: ButtonStyle { } } ) - .clipShape(Capsule()) + .clipShape(RoundedRectangle(cornerRadius: 100, style: .continuous)) .scaleEffect(configuration.isPressed ? 0.95 : 1) .animation(.interactiveSpring(), value: configuration.isPressed) .onChange(of: configuration.isPressed) { isPressed in diff --git a/Broadcast/Helper Views/PhotoPicker.swift b/Broadcast/Helper Views/PhotoPicker.swift index 5933fd2..9e4580c 100644 --- a/Broadcast/Helper Views/PhotoPicker.swift +++ b/Broadcast/Helper Views/PhotoPicker.swift @@ -10,7 +10,7 @@ import PhotosUI import SwiftUI struct UserSelectedMedia { - let id = UUID() + var id: String = UUID().uuidString var data: Data? var thumbnailData: Data? var mimeType: String? @@ -97,7 +97,8 @@ struct ImagePicker: UIViewControllerRepresentable { CGImageDestinationFinalize(imageDestination) dispatchQueue.sync { - let selection = UserSelectedMedia(data: rawImageData, + let selection = UserSelectedMedia(id: result.assetIdentifier ?? UUID().uuidString, + data: rawImageData, thumbnailData: data as Data, mimeType: utType?.preferredMIMEType ?? "image/jpeg") selectedImageDatas[index] = selection diff --git a/Broadcast/Helpers/TwitterClientManager.swift b/Broadcast/Helpers/TwitterClientManager.swift index 5f41a28..d9cd4d5 100644 --- a/Broadcast/Helpers/TwitterClientManager.swift +++ b/Broadcast/Helpers/TwitterClientManager.swift @@ -48,26 +48,23 @@ class TwitterClientManager: ObservableObject { self.user = try? await client.getMe(fields: [\.profileImageUrl]).data self.lastTweet = try? await client.userTimeline(fields: [\.createdAt, \.publicMetrics]).data.first } + self.client = client } @MainActor func signIn() { self.state = .busy - Twift.Authentication().requestUserCredentials(clientCredentials: ClientCredentials.credentials, callbackURL: ClientCredentials.callbackURL) { (userCredentials, error) in - Task.detached { + if let userCredentials = userCredentials { - let newClient = await Twift(.userAccessTokens(clientCredentials: ClientCredentials.credentials, userCredentials: userCredentials)) - await self.updateClient(newClient) + let newClient = Twift(.userAccessTokens(clientCredentials: ClientCredentials.credentials, userCredentials: userCredentials)) + self.client = newClient self.storeCredentials(credentials: userCredentials) } else if let error = error { print(error) } - - self.state = .idle - } } } @@ -76,6 +73,7 @@ class TwitterClientManager: ObservableObject { self.user = nil self.draft = .init() self.lastTweet = nil + self.client = nil KeychainWrapper.standard.remove(forKey: "broadcast-credentials") } @@ -103,6 +101,7 @@ class TwitterClientManager: ObservableObject { if response != nil { self.updateState(.idle) self.draft = .init() + self.selectedMedia = [] Haptics.shared.sendStandardFeedback(feedbackType: .success) } else if let error = error { print(error.localizedDescription) @@ -117,49 +116,53 @@ class TwitterClientManager: ObservableObject { } } + @MainActor func sendTweet(asReply: Bool = false) async { - updateState(.busy) - if asReply, let lastTweet = lastTweet { - draft.reply = .init(inReplyToTweetId: lastTweet.id) + guard let client = self.client else { + return } - - var mediaStrings: [String] = [] - for media in selectedMedia { - if let data = media.data, - let mimeTypeString = media.mimeType, - let mimeType = Media.MimeType(rawValue: mimeTypeString) { - let result = try? await client?.upload(mediaData: data, mimeType: mimeType) - - guard let result = result else { - self.state = .genericTextAndMediaError - print(result?.processingInfo?.error) - return - } - - if media.hasAltText { - try? await client?.addAltText(to: result.mediaIdString, text: media.altText) - } - - if result.processingInfo?.state != .failed && result.processingInfo?.state != .succeeded { - _ = try? await client?.checkMediaUploadSuccessful(result.mediaIdString) + + updateState(.busy) + + do { + if asReply, let lastTweet = lastTweet { + draft.reply = .init(inReplyToTweetId: lastTweet.id) + } + + var mediaStrings: [String] = [] + for media in selectedMedia { + if let data = media.data, + let mimeTypeString = media.mimeType, + let mimeType = Media.MimeType(rawValue: mimeTypeString) { + let result = try await client.upload(mediaData: data, mimeType: mimeType) + + + + if media.hasAltText { + try await client.addAltText(to: result.mediaIdString, text: media.altText) + } + + if result.processingInfo?.state != .failed && result.processingInfo?.state != .succeeded { + _ = try await client.checkMediaUploadSuccessful(result.mediaIdString) + } + + mediaStrings.append(result.mediaIdString) } - - mediaStrings.append(result.mediaIdString) } - } - - if !mediaStrings.isEmpty { - draft.media = MutableMedia(mediaIds: mediaStrings) - } - - let result = try? await client?.postTweet(draft) - - if let result = result { - self.lastTweet = try? await client?.getTweet(result.data.id).data + + if !mediaStrings.isEmpty { + draft.media = MutableMedia(mediaIds: mediaStrings) + } + + let result = try await client.postTweet(draft) + self.lastTweet = try await client.getTweet(result.data.id).data sendTweetCallback(response: result, error: nil) - } else { - sendTweetCallback(response: nil, error: nil) + } catch { + print(error) + sendTweetCallback(response: nil, error: error) } + + updateState(.idle) } /// Asynchronously update client state on the main thread @@ -258,9 +261,9 @@ extension TwitterClientManager { self.draft = MutableTweet(text: draft.text) // TODO: Fix draft media -// if let media = draft.media { -// self.draft.media = UIImage(data: media) -// } + // if let media = draft.media { + // self.draft.media = UIImage(data: media) + // } } let managedObjectContext = PersistanceController.shared.context From a907524a9e9a21391df8bed18a00009230cdeef6 Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Fri, 28 Jan 2022 12:41:28 +0000 Subject: [PATCH 14/36] Fix media uploads --- .../xcdebugger/Breakpoints_v2.xcbkptlist | 110 ------------------ Broadcast/Helpers/TwitterClientManager.swift | 4 +- 2 files changed, 1 insertion(+), 113 deletions(-) diff --git a/Broadcast.xcodeproj/xcuserdata/dte.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/Broadcast.xcodeproj/xcuserdata/dte.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist index de17bb1..853662d 100644 --- a/Broadcast.xcodeproj/xcuserdata/dte.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +++ b/Broadcast.xcodeproj/xcuserdata/dte.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -3,114 +3,4 @@ uuid = "561759D3-8D3F-4CFB-B9B4-4D047A58B1B1" type = "1" version = "2.0"> - - - - - - - - - - - - - - - - - - - - diff --git a/Broadcast/Helpers/TwitterClientManager.swift b/Broadcast/Helpers/TwitterClientManager.swift index d9cd4d5..614c894 100644 --- a/Broadcast/Helpers/TwitterClientManager.swift +++ b/Broadcast/Helpers/TwitterClientManager.swift @@ -136,13 +136,11 @@ class TwitterClientManager: ObservableObject { let mimeType = Media.MimeType(rawValue: mimeTypeString) { let result = try await client.upload(mediaData: data, mimeType: mimeType) - - if media.hasAltText { try await client.addAltText(to: result.mediaIdString, text: media.altText) } - if result.processingInfo?.state != .failed && result.processingInfo?.state != .succeeded { + if result.processingInfo != nil { _ = try await client.checkMediaUploadSuccessful(result.mediaIdString) } From c372ca530df3b05281b6a7f1abaa115b204b298e Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Fri, 28 Jan 2022 12:42:13 +0000 Subject: [PATCH 15/36] Disable media buttons when the app is busy --- Broadcast/ContentView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Broadcast/ContentView.swift b/Broadcast/ContentView.swift index 7441f58..6ce6f64 100644 --- a/Broadcast/ContentView.swift +++ b/Broadcast/ContentView.swift @@ -72,6 +72,7 @@ struct ContentView: View { .animation(.springAnimation, value: imageHeightCompensation) AttachmentThumbnail(media: $twitterClient.selectedMedia) + .disabled(twitterClient.state == .busy) } else { WelcomeView() } From b12dbb5cf094e297e7a3ce819a81115470ee9417 Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Fri, 28 Jan 2022 19:06:22 +0000 Subject: [PATCH 16/36] Fix typeahead search --- Broadcast.xcodeproj/project.pbxproj | 4 ++ Broadcast/Helper Views/ActionBarView.swift | 3 ++ .../Helper Views/BroadcastButtonStyle.swift | 2 +- Broadcast/Helpers/TwitterClientManager.swift | 41 ++++++++++++++++--- 4 files changed, 44 insertions(+), 6 deletions(-) diff --git a/Broadcast.xcodeproj/project.pbxproj b/Broadcast.xcodeproj/project.pbxproj index 2b323a4..7c49662 100644 --- a/Broadcast.xcodeproj/project.pbxproj +++ b/Broadcast.xcodeproj/project.pbxproj @@ -53,6 +53,7 @@ 71B82910268F2195002AEE72 /* LastTweetReplyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71B8290F268F2195002AEE72 /* LastTweetReplyView.swift */; }; 71BBAAE9268CF1BD004048A0 /* ComposerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71BBAAE8268CF1BD004048A0 /* ComposerView.swift */; }; 71BBAAEB268CF532004048A0 /* TwitterAPI-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 71BBAAEA268CF532004048A0 /* TwitterAPI-Info.plist */; }; + 71D283F127A469EF00640B2A /* AttributeScopes+TwitterEntities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71D283F027A469EF00640B2A /* AttributeScopes+TwitterEntities.swift */; }; 71E36FFB2689EED40078D956 /* ShakeModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71E36FFA2689EED40078D956 /* ShakeModifier.swift */; }; /* End PBXBuildFile section */ @@ -113,6 +114,7 @@ 71B8290F268F2195002AEE72 /* LastTweetReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastTweetReplyView.swift; sourceTree = ""; }; 71BBAAE8268CF1BD004048A0 /* ComposerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerView.swift; sourceTree = ""; }; 71BBAAEA268CF532004048A0 /* TwitterAPI-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "TwitterAPI-Info.plist"; sourceTree = ""; }; + 71D283F027A469EF00640B2A /* AttributeScopes+TwitterEntities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttributeScopes+TwitterEntities.swift"; sourceTree = ""; }; 71E36FFA2689EED40078D956 /* ShakeModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShakeModifier.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -220,6 +222,7 @@ 71800BFE26999BA6009D11A1 /* PersistanceController.swift */, 7199AE7F26B9892E001DEB46 /* Debouncer.swift */, 715AAE0B26C9589A002BCEA1 /* TestUtils.swift */, + 71D283F027A469EF00640B2A /* AttributeScopes+TwitterEntities.swift */, ); path = Helpers; sourceTree = ""; @@ -417,6 +420,7 @@ 71800BF426906BC0009D11A1 /* DraftsListView.swift in Sources */, 71E36FFB2689EED40078D956 /* ShakeModifier.swift in Sources */, 71800BF8269998BF009D11A1 /* UIImage.extension.swift in Sources */, + 71D283F127A469EF00640B2A /* AttributeScopes+TwitterEntities.swift in Sources */, 719087CF26891C7F005B96CE /* Array.extension.swift in Sources */, 7188E64D2687C6A1007CFD78 /* Binding.extension.swift in Sources */, 7199AE8026B9892E001DEB46 /* Debouncer.swift in Sources */, diff --git a/Broadcast/Helper Views/ActionBarView.swift b/Broadcast/Helper Views/ActionBarView.swift index af8a640..7a9ba0e 100644 --- a/Broadcast/Helper Views/ActionBarView.swift +++ b/Broadcast/Helper Views/ActionBarView.swift @@ -18,6 +18,9 @@ struct ActionBarView: View { private var pickerConfig: PHPickerConfiguration { var config = PHPickerConfiguration(photoLibrary: .shared()) + config.preferredAssetRepresentationMode = .current + config.selection = .ordered + if moreMediaAllowed && !twitterClient.selectedMedia.isEmpty { config.filter = .images config.selectionLimit = 4 diff --git a/Broadcast/Helper Views/BroadcastButtonStyle.swift b/Broadcast/Helper Views/BroadcastButtonStyle.swift index 65094b0..4de3bc1 100644 --- a/Broadcast/Helper Views/BroadcastButtonStyle.swift +++ b/Broadcast/Helper Views/BroadcastButtonStyle.swift @@ -89,7 +89,7 @@ struct BroadcastButtonStyle: ButtonStyle { } .padding(paddingSize) .background(background.padding(-paddingSize)) - .foregroundColor(foregroundColor) + .foregroundStyle(foregroundColor) .overlay( Group { if isLoading { diff --git a/Broadcast/Helpers/TwitterClientManager.swift b/Broadcast/Helpers/TwitterClientManager.swift index 614c894..615a152 100644 --- a/Broadcast/Helpers/TwitterClientManager.swift +++ b/Broadcast/Helpers/TwitterClientManager.swift @@ -172,6 +172,8 @@ class TwitterClientManager: ObservableObject { } @Published var userSearchResults: [User]? + + @MainActor func searchScreenNames(_ screenName: String) async { let url = URL(string: "https://twitter.com/i/search/typeahead.json?count=10&q=%23\(screenName)&result_type=users")! @@ -179,9 +181,12 @@ class TwitterClientManager: ObservableObject { "Authorization": typeaheadToken ] - // TODO: Fix user auth for typeahead search + guard case .userAccessTokens(_, let userCredentials) = client?.authenticationType else { + return + } + if let userId = user?.id { - headers["Cookie"] = "twid=u%3D\(userId);auth_token=\(ClientCredentials.credentials.key)" + headers["Cookie"] = "twid=u%3D\(userId);auth_token=\(userCredentials.key)" } var request = URLRequest(url: url) @@ -191,7 +196,9 @@ class TwitterClientManager: ObservableObject { do { let (result, _) = try await URLSession.shared.data(for: request) let decodedResult = try JSONDecoder().decode(TypeaheadResponse.self, from: result) - self.userSearchResults = decodedResult.users + withAnimation(.easeInOut(duration: 0.2)) { + self.userSearchResults = decodedResult.users?.compactMap { $0.toUser() } + } } catch { print(error) } @@ -219,6 +226,30 @@ class TwitterClientManager: ObservableObject { } } +fileprivate struct V1User: Codable { + let id_str: String + let name: String + let screen_name: String + let profile_image_url_https: URL + + func toUser() -> User? { + let jsonString = """ + { + "id": "\(id_str)", + "profileImageUrl": "\(profile_image_url_https)", + "name": "\(name)", + "username": "\(screen_name)" + } +""" + do { + return try JSONDecoder().decode(User.self, from: jsonString.data(using: .utf8)!) + } catch { + print(error) + return nil + } + } +} + /* MARK: Drafts */ extension TwitterClientManager { public func draftIsValid() -> Bool { @@ -325,7 +356,7 @@ extension TwitterClientManager { } } -struct TypeaheadResponse: Decodable { +fileprivate struct TypeaheadResponse: Decodable { var num_results: Int - var users: [User]? + var users: [V1User]? } From ba4563771a2eacf8115b23320d87a41a8de8d558 Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Fri, 28 Jan 2022 19:06:36 +0000 Subject: [PATCH 17/36] Add tentative attributed string extension --- .../AttributeScopes+TwitterEntities.swift | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 Broadcast/Helpers/AttributeScopes+TwitterEntities.swift diff --git a/Broadcast/Helpers/AttributeScopes+TwitterEntities.swift b/Broadcast/Helpers/AttributeScopes+TwitterEntities.swift new file mode 100644 index 0000000..4a69a3c --- /dev/null +++ b/Broadcast/Helpers/AttributeScopes+TwitterEntities.swift @@ -0,0 +1,34 @@ +// +// AttributeScopes+TwitterEntities.swift +// Broadcast +// +// Created by Daniel Eden on 28/01/2022. +// + +import Foundation + +struct Entity: Hashable, Codable { + let value: String +} + +struct EntityAttribute: CodableAttributedStringKey, MarkdownDecodableAttributedStringKey { + typealias Value = Entity + + static var name: String = "entity" + +} + +extension AttributeScopes { + struct BroadcastAttributes: AttributeScope { + let entity: EntityAttribute + let swiftUI: SwiftUIAttributes + } + + var broadcast: BroadcastAttributes.Type { BroadcastAttributes.self } +} + +extension AttributeDynamicLookup { + subscript(dynamicMember keyPath: KeyPath) -> T { + self[T.self] + } +} From d896dff50f98958773e59a949ee4d8762e6b2933 Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Sat, 29 Jan 2022 16:55:20 +0000 Subject: [PATCH 18/36] =?UTF-8?q?Use=20better=20media=20loading=20strategy?= =?UTF-8?q?=20and=20get=20ready=20for=20multiple=20account=20support=20?= =?UTF-8?q?=F0=9F=98=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Broadcast.xcodeproj/project.pbxproj | 8 +- .../xcshareddata/swiftpm/Package.resolved | 2 +- Broadcast/ContentView.swift | 10 +- .../DraftsModel.xcdatamodel/contents | 6 +- Broadcast/Helper Views/ActionBarView.swift | 14 +- .../Helper Views/AsyncLocalMediaPreview.swift | 69 ++++++++++ .../Helper Views/AttachmentThumbnail.swift | 123 ++++++++--------- .../Helper Views/BroadcastButtonStyle.swift | 21 +-- Broadcast/Helper Views/ComposerView.swift | 23 ++-- Broadcast/Helper Views/DraftsListView.swift | 16 +-- Broadcast/Helper Views/PhotoPicker.swift | 97 ++++---------- Broadcast/Helpers/ThemeHelper.swift | 2 +- Broadcast/Helpers/TwitterClientManager.swift | 99 ++++++++++---- Broadcast/SignOutView.swift | 126 ------------------ 14 files changed, 272 insertions(+), 344 deletions(-) create mode 100644 Broadcast/Helper Views/AsyncLocalMediaPreview.swift delete mode 100644 Broadcast/SignOutView.swift diff --git a/Broadcast.xcodeproj/project.pbxproj b/Broadcast.xcodeproj/project.pbxproj index 7c49662..1bbc1b2 100644 --- a/Broadcast.xcodeproj/project.pbxproj +++ b/Broadcast.xcodeproj/project.pbxproj @@ -33,7 +33,6 @@ 7188E64F2687CCD0007CFD78 /* PhotoPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E64E2687CCD0007CFD78 /* PhotoPicker.swift */; }; 7188E6532687D16C007CFD78 /* AttachmentThumbnail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E6522687D16C007CFD78 /* AttachmentThumbnail.swift */; }; 7188E65D26887DCD007CFD78 /* SwiftKeychainWrapper in Frameworks */ = {isa = PBXBuildFile; productRef = 7188E65C26887DCD007CFD78 /* SwiftKeychainWrapper */; }; - 7188E65F26889147007CFD78 /* SignOutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E65E26889147007CFD78 /* SignOutView.swift */; }; 7188E6612688A01F007CFD78 /* Font.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E6602688A01F007CFD78 /* Font.extension.swift */; }; 7188E6632688A0FC007CFD78 /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E6622688A0FC007CFD78 /* WelcomeView.swift */; }; 7188E6652688A436007CFD78 /* ThemeHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E6642688A436007CFD78 /* ThemeHelper.swift */; }; @@ -48,6 +47,7 @@ 7199AE8026B9892E001DEB46 /* Debouncer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7199AE7F26B9892E001DEB46 /* Debouncer.swift */; }; 71A1088A268B073B007E1FFB /* Haptics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71A10889268B073B007E1FFB /* Haptics.swift */; }; 71A6A264278C73AD00BF2387 /* TwitterAPI-Info.example.plist in Resources */ = {isa = PBXBuildFile; fileRef = 71A6A263278C73AD00BF2387 /* TwitterAPI-Info.example.plist */; }; + 71A9157927A59C9000706024 /* AsyncLocalMediaPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71A9157827A59C9000706024 /* AsyncLocalMediaPreview.swift */; }; 71AA4AC6268A032400B7B577 /* RemoteImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71AA4AC5268A032400B7B577 /* RemoteImage.swift */; }; 71B8290C268D0AC6002AEE72 /* TwitterClientManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71B8290B268D0AC6002AEE72 /* TwitterClientManager.swift */; }; 71B82910268F2195002AEE72 /* LastTweetReplyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71B8290F268F2195002AEE72 /* LastTweetReplyView.swift */; }; @@ -96,7 +96,6 @@ 7188E64C2687C6A1007CFD78 /* Binding.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Binding.extension.swift; sourceTree = ""; }; 7188E64E2687CCD0007CFD78 /* PhotoPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPicker.swift; sourceTree = ""; }; 7188E6522687D16C007CFD78 /* AttachmentThumbnail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentThumbnail.swift; sourceTree = ""; }; - 7188E65E26889147007CFD78 /* SignOutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignOutView.swift; sourceTree = ""; }; 7188E6602688A01F007CFD78 /* Font.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Font.extension.swift; sourceTree = ""; }; 7188E6622688A0FC007CFD78 /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; }; 7188E6642688A436007CFD78 /* ThemeHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeHelper.swift; sourceTree = ""; }; @@ -109,6 +108,7 @@ 7199AE7F26B9892E001DEB46 /* Debouncer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debouncer.swift; sourceTree = ""; }; 71A10889268B073B007E1FFB /* Haptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Haptics.swift; sourceTree = ""; }; 71A6A263278C73AD00BF2387 /* TwitterAPI-Info.example.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "TwitterAPI-Info.example.plist"; sourceTree = ""; }; + 71A9157827A59C9000706024 /* AsyncLocalMediaPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncLocalMediaPreview.swift; sourceTree = ""; }; 71AA4AC5268A032400B7B577 /* RemoteImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImage.swift; sourceTree = ""; }; 71B8290B268D0AC6002AEE72 /* TwitterClientManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwitterClientManager.swift; sourceTree = ""; }; 71B8290F268F2195002AEE72 /* LastTweetReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastTweetReplyView.swift; sourceTree = ""; }; @@ -173,7 +173,6 @@ 7188E6312687B0FF007CFD78 /* Info.plist */, 7188E6282687B0FE007CFD78 /* BroadcastApp.swift */, 7188E62A2687B0FE007CFD78 /* ContentView.swift */, - 7188E65E26889147007CFD78 /* SignOutView.swift */, 7188E6622688A0FC007CFD78 /* WelcomeView.swift */, 7188E62C2687B0FF007CFD78 /* Assets.xcassets */, 7188E6392687B192007CFD78 /* Extensions */, @@ -246,6 +245,7 @@ 7199AE7B26B97327001DEB46 /* UserView.swift */, 7199AE7D26B973B2001DEB46 /* MentionBar.swift */, 7101073526C810AC00A713A5 /* NullStateView.swift */, + 71A9157827A59C9000706024 /* AsyncLocalMediaPreview.swift */, ); path = "Helper Views"; sourceTree = ""; @@ -418,6 +418,7 @@ 71800BFF26999BA6009D11A1 /* PersistanceController.swift in Sources */, 71B82910268F2195002AEE72 /* LastTweetReplyView.swift in Sources */, 71800BF426906BC0009D11A1 /* DraftsListView.swift in Sources */, + 71A9157927A59C9000706024 /* AsyncLocalMediaPreview.swift in Sources */, 71E36FFB2689EED40078D956 /* ShakeModifier.swift in Sources */, 71800BF8269998BF009D11A1 /* UIImage.extension.swift in Sources */, 71D283F127A469EF00640B2A /* AttributeScopes+TwitterEntities.swift in Sources */, @@ -427,7 +428,6 @@ 711F3FFA268F50C800605C89 /* Animation.extension.swift in Sources */, 71AA4AC6268A032400B7B577 /* RemoteImage.swift in Sources */, 7188E6672688B99E007CFD78 /* VisualEffectView.swift in Sources */, - 7188E65F26889147007CFD78 /* SignOutView.swift in Sources */, 71A1088A268B073B007E1FFB /* Haptics.swift in Sources */, 71BBAAE9268CF1BD004048A0 /* ComposerView.swift in Sources */, 715AAE0C26C9589A002BCEA1 /* TestUtils.swift in Sources */, diff --git a/Broadcast.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Broadcast.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index bdd7ab7..b8b3bf0 100644 --- a/Broadcast.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Broadcast.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -24,7 +24,7 @@ "repositoryURL": "https://github.com/daneden/Twift.git", "state": { "branch": "main", - "revision": "ef90bf5165f5d046908faddfe20ae6ecdfcacf79", + "revision": "b4357030d7b266faaacf40bf886836dd06cadf88", "version": null } }, diff --git a/Broadcast/ContentView.swift b/Broadcast/ContentView.swift index 6ce6f64..a9b9176 100644 --- a/Broadcast/ContentView.swift +++ b/Broadcast/ContentView.swift @@ -18,7 +18,6 @@ struct ContentView: View { @EnvironmentObject var twitterClient: TwitterClientManager @State private var photoPickerIsPresented = false - @State private var signOutScreenIsPresented = false @State private var repliesSheetIsPresented = false @State private var sendingTweet = false @@ -64,7 +63,7 @@ struct ContentView: View { } if twitterClient.user != nil { - ComposerView(signOutScreenIsPresented: $signOutScreenIsPresented) + ComposerView() .frame( height: geom.size.height - (bottomPadding + (captionSize * 2)) - imageHeightCompensation, alignment: .topLeading @@ -72,7 +71,7 @@ struct ContentView: View { .animation(.springAnimation, value: imageHeightCompensation) AttachmentThumbnail(media: $twitterClient.selectedMedia) - .disabled(twitterClient.state == .busy) + .disabled(twitterClient.state == .busy()) } else { WelcomeView() } @@ -86,7 +85,7 @@ struct ContentView: View { if twitterClient.user != nil { ActionBarView(replying: $replying) } else { - Button(action: { twitterClient.signIn() }) { + Button(action: { Task { await twitterClient.signIn() } }) { Label("Sign In With Twitter", image: "twitter.fill") .font(.broadcastHeadline) } @@ -102,9 +101,6 @@ struct ContentView: View { ) .gesture(DragGesture().onEnded({ _ in UIApplication.shared.endEditing() })) } - .sheet(isPresented: $signOutScreenIsPresented) { - SignOutView() - } .sheet(isPresented: $repliesSheetIsPresented) { RepliesListView(tweet: twitterClient.lastTweet) .accentColor(ThemeHelper.shared.color) diff --git a/Broadcast/DraftsModel.xcdatamodeld/DraftsModel.xcdatamodel/contents b/Broadcast/DraftsModel.xcdatamodeld/DraftsModel.xcdatamodel/contents index 96c750d..62bc906 100644 --- a/Broadcast/DraftsModel.xcdatamodeld/DraftsModel.xcdatamodel/contents +++ b/Broadcast/DraftsModel.xcdatamodeld/DraftsModel.xcdatamodel/contents @@ -1,12 +1,12 @@ - + - + - + \ No newline at end of file diff --git a/Broadcast/Helper Views/ActionBarView.swift b/Broadcast/Helper Views/ActionBarView.swift index 7a9ba0e..762ead8 100644 --- a/Broadcast/Helper Views/ActionBarView.swift +++ b/Broadcast/Helper Views/ActionBarView.swift @@ -24,7 +24,7 @@ struct ActionBarView: View { if moreMediaAllowed && !twitterClient.selectedMedia.isEmpty { config.filter = .images config.selectionLimit = 4 - config.preselectedAssetIdentifiers = twitterClient.selectedMedia.map(\.id) + config.preselectedAssetIdentifiers = twitterClient.selectedMedia.map(\.key) } else { config.filter = .any(of: [.images, .videos]) } @@ -33,18 +33,20 @@ struct ActionBarView: View { } private var moreMediaAllowed: Bool { - if twitterClient.selectedMedia.contains(where: { $0.mimeType?.contains("video") ?? true }) { return false } - if twitterClient.selectedMedia.contains(where: { $0.mimeType?.contains("gif") ?? true }) { return false } + //if twitterClient.selectedMedia.contains(where: { $0.value?.mimeType == .mov || $0.value?.mimeType == .gif }) { return false } if twitterClient.selectedMedia.count == 4 { return false } return true } var body: some View { publishingActions - .disabled(twitterClient.state == .busy) + .disabled(twitterClient.state == .busy()) .sheet(isPresented: $photoPickerIsPresented) { ImagePicker(configuration: pickerConfig, selection: $twitterClient.selectedMedia) } + .onLongPressGesture { + ThemeHelper.shared.rotateTheme() + } } var publishingActions: some View { @@ -72,7 +74,7 @@ struct ActionBarView: View { BroadcastButtonStyle( prominence: replying ? .primary : .secondary, isFullWidth: replying, - isLoading: twitterClient.state == .busy && replying + isLoading: twitterClient.state == .busy() && replying ) ) .disabled(replying && !twitterClient.draftIsValid()) @@ -100,7 +102,7 @@ struct ActionBarView: View { BroadcastButtonStyle( prominence: !replying ? .primary : .secondary, isFullWidth: !replying, - isLoading: twitterClient.state == .busy && !replying + isLoading: twitterClient.state == .busy() && !replying ) ) .disabled(!replying && !twitterClient.draftIsValid()) diff --git a/Broadcast/Helper Views/AsyncLocalMediaPreview.swift b/Broadcast/Helper Views/AsyncLocalMediaPreview.swift new file mode 100644 index 0000000..2f8c07d --- /dev/null +++ b/Broadcast/Helper Views/AsyncLocalMediaPreview.swift @@ -0,0 +1,69 @@ +// +// AsyncLocalMediaPreview.swift +// Broadcast +// +// Created by Daniel Eden on 29/01/2022. +// + +import SwiftUI +import PhotosUI + +struct AsyncLocalMediaPreview: View { + private enum PreviewLoadingState { + case loaded(_ image: UIImage) + case failed + case loading + case loadingWithProgress(_ progress: Progress) + } + var assetId: String + var asset: PHPickerResult + + @State private var state: PreviewLoadingState = .loading + @State var loadingProgress: Progress? + + var body: some View { + Group { + switch state { + case .loaded(let image): + Image(uiImage: image) + .resizable() + .scaledToFit() + case .failed: + Label("Cannot load media", systemImage: "eye.slash") + .foregroundStyle(.secondary) + .padding() + case .loading: + ProgressView() + .padding() + case .loadingWithProgress(let progress): + ProgressView(progress) + .padding() + } + } + .transition(.opacity) + .frame(maxWidth: .infinity, minHeight: 48) + .background(.thinMaterial) + .task { await loadPreview() } + .onChange(of: loadingProgress) { value in + if let value = value, + !value.isFinished { + self.state = .loadingWithProgress(value) + } + } + } + + func loadPreview() async { + let itemProvider = asset.itemProvider + if itemProvider.canLoadObject(ofClass: UIImage.self) { + loadingProgress = itemProvider.loadObject(ofClass: UIImage.self) { image, error in + if let image = image as? UIImage { + self.state = .loaded(image) + } else { + self.state = .failed + } + } + } else { + self.state = .failed + } + } +} diff --git a/Broadcast/Helper Views/AttachmentThumbnail.swift b/Broadcast/Helper Views/AttachmentThumbnail.swift index 30d2db4..c584647 100644 --- a/Broadcast/Helper Views/AttachmentThumbnail.swift +++ b/Broadcast/Helper Views/AttachmentThumbnail.swift @@ -6,106 +6,96 @@ // import SwiftUI +import PhotosUI extension String: Identifiable { public var id: String { self } } struct AttachmentThumbnail: View { - @Binding var media: [UserSelectedMedia] + @EnvironmentObject var twitterClient: TwitterClientManager + @Binding var media: [String: PHPickerResult] @State private var altTextSheetIsPresented = false @State private var selectedMediaId: String? var body: some View { VStack { if let media = media, !media.isEmpty { - ForEach(media, id: \.id) { item in - if let previewData = item.thumbnailData, - let image = UIImage(data: previewData) { - ZStack(alignment: .top) { - Image(uiImage: image.fixedOrientation) - .resizable() - .aspectRatio(image.size, contentMode: .fill) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - - HStack { - if item.canAddAltText { - Button(action: { selectedMediaId = item.id }) { - Label("Edit Alt Text", systemImage: "captions.bubble") - .labelStyle(.iconOnly) - } - .buttonStyle(BroadcastButtonStyle(paddingSize: 8, prominence: item.hasAltText ? .primary : .tertiary, isFullWidth: false)) - .clipShape(Circle()) - .offset(x: 8, y: 8) - .sheet(item: $selectedMediaId) { id in - AltTextSheet(mediaId: id) - } - } - - Spacer() - - Button(action: { removeImage(item.id) }) { - Label("Remove Image", systemImage: "xmark") + ForEach(Array(media.keys), id: \.self) { key in + ZStack(alignment: .top) { + AsyncLocalMediaPreview(assetId: key, asset: media[key]!) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + + HStack { + if let item = media[key], + item.allowsAltText { + Button(action: { selectedMediaId = key }) { + Label("Edit Alt Text", systemImage: "captions.bubble") .labelStyle(.iconOnly) } - .buttonStyle(BroadcastButtonStyle(paddingSize: 8, prominence: .tertiary, isFullWidth: false)) + .buttonStyle(BroadcastButtonStyle(paddingSize: 8, prominence: itemHasAltText(key) ? .primary : .tertiary, isFullWidth: false)) .clipShape(Circle()) - .offset(x: -8, y: 8) + .offset(x: 8, y: 8) + .sheet(item: $selectedMediaId) { id in + AltTextSheet(assetId: id, asset: item) + } } + + Spacer() + + Button(action: { removeImage(key) }) { + Label("Remove Image", systemImage: "xmark") + .labelStyle(.iconOnly) + } + .buttonStyle(BroadcastButtonStyle(paddingSize: 8, prominence: .tertiary, isFullWidth: false)) + .clipShape(Circle()) + .offset(x: -8, y: 8) } } } } } - .transition(.opacity) + } + + func itemHasAltText(_ id: String) -> Bool { + return !(twitterClient.mediaAltText[id]?.isEmpty ?? true) } func removeImage(_ id: String) { withAnimation { - media.removeAll(where: { $0.id == id }) + _ = media.removeValue(forKey: id) } } } fileprivate struct AltTextSheet: View { @EnvironmentObject var twitterClient: TwitterClientManager - @Environment(\.presentationMode) var presentationMode - var mediaId: String - var media: Binding { - $twitterClient.selectedMedia.first(where: { $0.wrappedValue.id == mediaId })! - } + var assetId: String + var asset: PHPickerResult - var preview: UIImage? { - guard let data = media.wrappedValue.thumbnailData, - let image = UIImage(data: data) else { - return nil - } - - return image - } + @State var altText = "" var body: some View { NavigationView { Form { - if let preview = preview { - HStack { - Spacer() - Image(uiImage: preview) - .resizable() - .aspectRatio(contentMode: .fit) - .cornerRadius(8) - .frame(maxWidth: 200, maxHeight: 200) - Spacer() - } - .padding() - .listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0)) - .listRowBackground(Color.clear) + HStack { + Spacer() + AsyncLocalMediaPreview(assetId: assetId, asset: asset) + .cornerRadius(8) + .frame(minWidth: 0, maxWidth: 200, minHeight: 0, maxHeight: 200) + Spacer() } + .padding() + .listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0)) + .listRowBackground(Color.clear) Section(footer: Text("You can add a description, sometimes called alt-text, to your photos so they’re accessible to even more people, including people who are blind or have low vision. Good descriptions are concise, but present what’s in your photos accurately enough to understand their context.")) { - TextField("Enter Alt Text", text: media.altText) + TextField("Enter Alt Text", text: $altText) + .onSubmit { + self.presentationMode.wrappedValue.dismiss() + } } } .navigationTitle("Edit Alt Text") @@ -115,12 +105,25 @@ fileprivate struct AltTextSheet: View { presentationMode.wrappedValue.dismiss() } } + .onDisappear { + withAnimation(.springAnimation) { + twitterClient.mediaAltText[assetId] = altText + } + } + .onAppear { + if let currentValue = twitterClient.mediaAltText[assetId] { + DispatchQueue.main.async { + altText = currentValue + } + } + } } } } + struct ThumbnailFilmstrip_Previews: PreviewProvider { static var previews: some View { - AttachmentThumbnail(media: .constant([])) + AttachmentThumbnail(media: .constant([:])) } } diff --git a/Broadcast/Helper Views/BroadcastButtonStyle.swift b/Broadcast/Helper Views/BroadcastButtonStyle.swift index 4de3bc1..191ed73 100644 --- a/Broadcast/Helper Views/BroadcastButtonStyle.swift +++ b/Broadcast/Helper Views/BroadcastButtonStyle.swift @@ -28,6 +28,8 @@ struct BroadcastLabelStyle: LabelStyle { } struct BroadcastButtonStyle: ButtonStyle { + @Environment(\.isEnabled) var isEnabled + enum Prominence { case primary, secondary, tertiary, destructive } @@ -37,19 +39,6 @@ struct BroadcastButtonStyle: ButtonStyle { var isFullWidth = true var isLoading = false - var foregroundStyle: HierarchicalShapeStyle { - switch prominence { - case .primary: - return .primary - case .secondary: - return .secondary - case .tertiary: - return .tertiary - case .destructive: - return .primary - } - } - var background: some View { Group { switch prominence { @@ -60,7 +49,7 @@ struct BroadcastButtonStyle: ButtonStyle { case .tertiary: Color.clear.background(.ultraThinMaterial) case .destructive: - Color(.systemRed) + Color.red } } } @@ -70,7 +59,7 @@ struct BroadcastButtonStyle: ButtonStyle { case .secondary: return .accentColor case .tertiary: - return .secondary + return isEnabled ? .primary : .secondary default: return .white } @@ -93,7 +82,7 @@ struct BroadcastButtonStyle: ButtonStyle { .overlay( Group { if isLoading { - ProgressView() + ProgressView().tint(foregroundColor) } } ) diff --git a/Broadcast/Helper Views/ComposerView.swift b/Broadcast/Helper Views/ComposerView.swift index 0d94205..28467f8 100644 --- a/Broadcast/Helper Views/ComposerView.swift +++ b/Broadcast/Helper Views/ComposerView.swift @@ -21,7 +21,6 @@ fileprivate let placeholderCandidates: [String] = [ struct ComposerView: View { let debouncer = Debouncer(timeInterval: 0.3) - @Binding var signOutScreenIsPresented: Bool @EnvironmentObject var twitterClient: TwitterClientManager @ScaledMetric private var minComposerHeight: CGFloat = 120 @@ -79,20 +78,24 @@ struct ComposerView: View { ZStack(alignment: .bottom) { VStack(alignment: .trailing) { HStack(alignment: .top) { - AsyncImage(url: twitterClient.user?.profileImageUrl) { image in + Menu { + Section { + Button(role: .destructive, action: {twitterClient.signOut()}) { + Label("Sign Out", systemImage: "person.badge.minus") + } + } + } label: { + AsyncImage(url: twitterClient.user?.profileImageUrlLarger) { image in image .resizable() .aspectRatio(contentMode: .fill) - .frame(width: 36, height: 36) .cornerRadius(36) } placeholder: { ProgressView() } - .onTapGesture { - signOutScreenIsPresented = true - } - .accessibilityIdentifier("profilePhotoButton") - + .background(.regularMaterial) + .frame(width: 36, height: 36) + } ZStack(alignment: .topLeading) { Text(tweetText.isEmpty ? placeholder : tweetText) @@ -136,7 +139,7 @@ struct ComposerView: View { .multilineTextAlignment(.trailing) } } - .disabled(twitterClient.state == .busy) + .disabled(twitterClient.state == .busy()) .padding() .background(Color(.tertiarySystemGroupedBackground)) .onShake { @@ -194,6 +197,6 @@ struct ComposerView: View { struct ComposerView_Previews: PreviewProvider { static var previews: some View { - ComposerView(signOutScreenIsPresented: .constant(false)) + ComposerView() } } diff --git a/Broadcast/Helper Views/DraftsListView.swift b/Broadcast/Helper Views/DraftsListView.swift index fc8359b..10570c5 100644 --- a/Broadcast/Helper Views/DraftsListView.swift +++ b/Broadcast/Helper Views/DraftsListView.swift @@ -43,14 +43,14 @@ struct DraftsListView: View { Spacer() - if let imageData = draft.media, - let image = UIImage(data: imageData) { - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: thumbnailSize, height: thumbnailSize) - .cornerRadius(8) - } +// if let imageData = draft.media, +// let image = UIImage(data: imageData) { +// Image(uiImage: image) +// .resizable() +// .aspectRatio(contentMode: .fill) +// .frame(width: thumbnailSize, height: thumbnailSize) +// .cornerRadius(8) +// } } .contentShape(Rectangle()) .onTapGesture { diff --git a/Broadcast/Helper Views/PhotoPicker.swift b/Broadcast/Helper Views/PhotoPicker.swift index 9e4580c..a63c5dc 100644 --- a/Broadcast/Helper Views/PhotoPicker.swift +++ b/Broadcast/Helper Views/PhotoPicker.swift @@ -8,23 +8,35 @@ import Foundation import PhotosUI import SwiftUI +import Twift -struct UserSelectedMedia { - var id: String = UUID().uuidString - var data: Data? - var thumbnailData: Data? - var mimeType: String? - var altText: String = "" +extension PHPickerResult { + var mediaType: UTType? { + guard let registeredTypeIdentifier = self.itemProvider.registeredTypeIdentifiers.first else { + return nil + } + return UTType(registeredTypeIdentifier) + } + + var mediaMimeType: Media.MimeType? { + guard let utTypeMimeType = mediaType?.preferredMIMEType else { return nil } + return .init(rawValue: utTypeMimeType) + } - var hasAltText: Bool { !altText.isEmpty } - var canAddAltText: Bool { mimeType?.contains("image") ?? false } + var allowsAltText: Bool { + guard let mimeType = mediaMimeType else { return false } + switch mimeType { + case .gif, .jpeg: return true + default: return false + } + } } struct ImagePicker: UIViewControllerRepresentable { @Environment(\.presentationMode) var presentationMode var configuration: PHPickerConfiguration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared()) - @Binding var selection: [UserSelectedMedia] + @Binding var selection: [String: PHPickerResult] func makeUIViewController(context: UIViewControllerRepresentableContext) -> PHPickerViewController { let controller = PHPickerViewController(configuration: configuration) @@ -49,72 +61,11 @@ struct ImagePicker: UIViewControllerRepresentable { } func picker(_: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { - if results.isEmpty { - self.parent.presentationMode.wrappedValue.dismiss() + for result in results { + self.parent.selection[result.assetIdentifier!] = result } - let dispatchQueue = DispatchQueue(label: "me.daneden.Twift_SwiftUI.AlbumImageQueue") - var selectedImageDatas = [UserSelectedMedia?](repeating: nil, count: results.count) // Awkwardly named, sure - var totalConversionsCompleted = 0 - for (index, result) in results.enumerated() { - result.itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.image.identifier) { url, error in - guard let url = url, let rawImageData = try? Data(contentsOf: url) else { - dispatchQueue.sync { totalConversionsCompleted += 1 } - return - } - - let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary - - guard let source = CGImageSourceCreateWithURL(url as CFURL, sourceOptions) else { - dispatchQueue.sync { totalConversionsCompleted += 1 } - return - } - - let downsampleOptions = [ - kCGImageSourceCreateThumbnailFromImageAlways: true, - kCGImageSourceCreateThumbnailWithTransform: false, - kCGImageSourceThumbnailMaxPixelSize: 2_000, - ] as CFDictionary - - guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions) else { - dispatchQueue.sync { totalConversionsCompleted += 1} - return - } - - let data = NSMutableData() - let utType = UTType.init(filenameExtension: url.pathExtension) - - guard let imageDestination = CGImageDestinationCreateWithData(data, (utType ?? UTType.jpeg).identifier as CFString, 1, nil) else { - dispatchQueue.sync { totalConversionsCompleted += 1 } - return - } - - let destinationProperties = [ - kCGImageDestinationLossyCompressionQuality: utType == .png ? 1.0 : 0.75 - ] as CFDictionary - - CGImageDestinationAddImage(imageDestination, cgImage, destinationProperties) - CGImageDestinationFinalize(imageDestination) - - dispatchQueue.sync { - let selection = UserSelectedMedia(id: result.assetIdentifier ?? UUID().uuidString, - data: rawImageData, - thumbnailData: data as Data, - mimeType: utType?.preferredMIMEType ?? "image/jpeg") - selectedImageDatas[index] = selection - totalConversionsCompleted += 1 - - if totalConversionsCompleted == results.count { - print(selectedImageDatas) - - DispatchQueue.main.async { - self.parent.selection.append(contentsOf: selectedImageDatas.compactMap { $0 }) - } - self.parent.presentationMode.wrappedValue.dismiss() - } - } - } - } + self.parent.presentationMode.wrappedValue.dismiss() } } } diff --git a/Broadcast/Helpers/ThemeHelper.swift b/Broadcast/Helpers/ThemeHelper.swift index 1e4255e..5297942 100644 --- a/Broadcast/Helpers/ThemeHelper.swift +++ b/Broadcast/Helpers/ThemeHelper.swift @@ -41,7 +41,7 @@ class ThemeHelper: ObservableObject { @AppStorage("themeColorIndex") private var currentColorIndex = 0 { didSet { - color = allColors[currentColorIndex] + withAnimation { color = allColors[currentColorIndex] } } } diff --git a/Broadcast/Helpers/TwitterClientManager.swift b/Broadcast/Helpers/TwitterClientManager.swift index 615a152..b5c2d5c 100644 --- a/Broadcast/Helpers/TwitterClientManager.swift +++ b/Broadcast/Helpers/TwitterClientManager.swift @@ -13,6 +13,7 @@ import UIKit import AuthenticationServices import SwiftKeychainWrapper import SwiftUI +import PhotosUI let typeaheadToken = "AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA" @@ -23,7 +24,9 @@ class TwitterClientManager: ObservableObject { @Published var state: State = .initializing @Published var lastTweet: Tweet? @Published var client: Twift? - @Published var selectedMedia: [UserSelectedMedia] = [] + + @Published var selectedMedia: [String: PHPickerResult] = [:] + @Published var mediaAltText: [String: String] = [:] @MainActor init() { @@ -43,29 +46,44 @@ class TwitterClientManager: ObservableObject { } @MainActor - private func updateClient(_ client: Twift?) async { - if let client = client { - self.user = try? await client.getMe(fields: [\.profileImageUrl]).data - self.lastTweet = try? await client.userTimeline(fields: [\.createdAt, \.publicMetrics]).data.first - } + private func updateClient(_ client: Twift?, animated: Bool = false) async { + guard let client = client else { return } + self.client = client + let user = try? await client.getMe(fields: [\.profileImageUrl]).data + let lastTweet = try? await client.userTimeline(fields: [\.createdAt, \.publicMetrics]).data.first + + if animated { + withAnimation { + self.user = user + self.lastTweet = lastTweet + } + } else { + self.user = user + self.lastTweet = lastTweet + } } @MainActor - func signIn() { - self.state = .busy - Twift.Authentication().requestUserCredentials(clientCredentials: ClientCredentials.credentials, - callbackURL: ClientCredentials.callbackURL) { (userCredentials, error) in - + func signIn() async { + self.state = .busy() + + let client: Twift? = await withUnsafeContinuation { continuation in + Twift.Authentication().requestUserCredentials(clientCredentials: ClientCredentials.credentials, + callbackURL: ClientCredentials.callbackURL) { (userCredentials, error) in if let userCredentials = userCredentials { let newClient = Twift(.userAccessTokens(clientCredentials: ClientCredentials.credentials, userCredentials: userCredentials)) - self.client = newClient self.storeCredentials(credentials: userCredentials) + continuation.resume(returning: newClient) } else if let error = error { print(error) + continuation.resume(returning: nil) } - self.state = .idle + } } + + await self.updateClient(client, animated: true) + self.state = .idle } @MainActor @@ -101,12 +119,12 @@ class TwitterClientManager: ObservableObject { if response != nil { self.updateState(.idle) self.draft = .init() - self.selectedMedia = [] + withAnimation { self.selectedMedia = [:] } Haptics.shared.sendStandardFeedback(feedbackType: .success) } else if let error = error { print(error.localizedDescription) - if draft.media != nil { + if !self.selectedMedia.isEmpty { self.updateState(.genericTextAndMediaError) } else { self.updateState(.genericTextError) @@ -122,7 +140,7 @@ class TwitterClientManager: ObservableObject { return } - updateState(.busy) + updateState(.busy()) do { if asReply, let lastTweet = lastTweet { @@ -130,22 +148,44 @@ class TwitterClientManager: ObservableObject { } var mediaStrings: [String] = [] - for media in selectedMedia { - if let data = media.data, - let mimeTypeString = media.mimeType, - let mimeType = Media.MimeType(rawValue: mimeTypeString) { - let result = try await client.upload(mediaData: data, mimeType: mimeType) - - if media.hasAltText { - try await client.addAltText(to: result.mediaIdString, text: media.altText) + for (key, media) in selectedMedia { + let media: (Data, Media.MimeType)? = await withUnsafeContinuation { continuation in + let itemProvider = media.itemProvider + guard let utType = itemProvider.registeredTypeIdentifiers.reduce(nil, { (guess: UTType?, current: String) in + if guess == nil { + return UTType(current) + } else { + return nil + } + }) else { + return continuation.resume(returning: nil) } - if result.processingInfo != nil { - _ = try await client.checkMediaUploadSuccessful(result.mediaIdString) - } + itemProvider.loadDataRepresentation(forTypeIdentifier: utType.identifier, completionHandler: { data, error in + if let data = data, + let mimeType = Media.MimeType(rawValue: utType.preferredMIMEType!) { + continuation.resume(returning: (data, mimeType)) + } else { + print("unable to load data for object with type", utType) + } + }) + } + + guard let (data, mimeType) = media else { + return updateState(.genericTextAndMediaError) + } + + let result = try await client.upload(mediaData: data, mimeType: mimeType) + + if let altText = mediaAltText[key] { + try await client.addAltText(to: result.mediaIdString, text: altText) + } - mediaStrings.append(result.mediaIdString) + if result.processingInfo != nil { + _ = try await client.checkMediaUploadSuccessful(result.mediaIdString) } + + mediaStrings.append(result.mediaIdString) } if !mediaStrings.isEmpty { @@ -305,7 +345,8 @@ extension TwitterClientManager { // MARK: Models extension TwitterClientManager { enum State: Equatable { - case idle, busy, initializing + case idle, initializing + case busy(_ progress: Progress? = nil) case error(_: String? = nil) static var genericTextError = State.error("Oh man, something went wrong sending that tweet. It might be too long.") diff --git a/Broadcast/SignOutView.swift b/Broadcast/SignOutView.swift deleted file mode 100644 index 9c64411..0000000 --- a/Broadcast/SignOutView.swift +++ /dev/null @@ -1,126 +0,0 @@ -// -// SignOutView.swift -// Broadcast -// -// Created by Daniel Eden on 27/06/2021. -// - -import SwiftUI -import CoreHaptics - -struct SignOutView: View { - @Environment(\.colorScheme) var colorScheme - @Environment(\.presentationMode) var presentationMode - @EnvironmentObject var twitterClient: TwitterClientManager - @EnvironmentObject var themeHelper: ThemeHelper - - @State private var offset = CGSize.zero - @State private var willDelete = false - - @ScaledMetric var size: CGFloat = 88 - - var labelOpacity: Double { - Double(1 - abs(offset.height) / 200) - } - - @State private var animating = false - - var body: some View { - VStack { - Spacer() - if let screenName = twitterClient.user?.username { - Label("Drag to sign out @\(screenName)", systemImage: "arrow.down.circle") - .font(.broadcastBody.bold()) - .foregroundColor(.secondary) - .padding() - .opacity(labelOpacity) - } - - VStack { - Group { - AsyncImage(url: twitterClient.user?.profileImageUrl) { image in - image - .resizable() - .aspectRatio(contentMode: .fill) - .clipShape(Circle()) - } placeholder: { - Image(systemName: "person.crop.circle.fill") - .resizable() - } - } - .shadow( - color: (willDelete || colorScheme == .dark) ? .black.opacity(0.2) : .accentColor, - radius: 8, x: 0, y: 4 - ) - .foregroundColor(.white) - .padding(8) - .frame(width: size, height: size) - .background(willDelete - ? Color(.secondarySystemBackground) - : .accentColor.opacity(colorScheme == .dark ? 0.9 : 0.5) - ) - .clipShape(Circle()) - .onTapGesture { - themeHelper.rotateTheme() - Haptics.shared.sendStandardFeedback(feedbackType: .success) - } - .offset(offset) - .highPriorityGesture( - DragGesture() - .onChanged { gesture in - withAnimation { self.offset.height = min(gesture.translation.height, 200 + size) } - - withAnimation(.interactiveSpring()) { willDelete = self.offset.height >= 200 } - } - - .onEnded { _ in - if self.offset.height >= 200 { - startSignOut() - } else { - withAnimation(.interactiveSpring(response: 0.3, dampingFraction: 0.6, blendDuration: 0.4)) { - self.offset = .zero - willDelete = false - } - } - } - ) - .accessibilityIdentifier("logoutProfilePhotoHandle") - - Color.clear.frame(height: 180) - - Image(systemName: "trash") - .resizable() - .aspectRatio(contentMode: .fit) - .padding(size * 0.3) - .frame(width: size, height: size) - .background(willDelete ? Color(.systemRed) : Color(.secondarySystemBackground)) - .foregroundColor(willDelete ? .white : .primary) - .clipShape(Circle()) - .accessibilityIdentifier("logoutTarget") - } - Spacer() - - Button(action: { presentationMode.wrappedValue.dismiss() }) { - Text("Close") - }.buttonStyle(BroadcastButtonStyle(prominence: .tertiary)) - .opacity(labelOpacity) - } - .padding() - .onChange(of: willDelete) { willDelete in - let v: Float = willDelete ? 1 : 0.3 - Haptics.shared.sendFeedback(intensity: v, sharpness: v) - } - .accentColor(themeHelper.color) - } - - func startSignOut() { - twitterClient.signOut() - presentationMode.wrappedValue.dismiss() - } -} - -struct SignOutView_Previews: PreviewProvider { - static var previews: some View { - SignOutView() - } -} From e481c0b8339c8939287adb4e71c3a1982061e5e2 Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Sat, 29 Jan 2022 16:55:37 +0000 Subject: [PATCH 19/36] Ok so that didn't do anything --- Broadcast/Helpers/ThemeHelper.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Broadcast/Helpers/ThemeHelper.swift b/Broadcast/Helpers/ThemeHelper.swift index 5297942..1e4255e 100644 --- a/Broadcast/Helpers/ThemeHelper.swift +++ b/Broadcast/Helpers/ThemeHelper.swift @@ -41,7 +41,7 @@ class ThemeHelper: ObservableObject { @AppStorage("themeColorIndex") private var currentColorIndex = 0 { didSet { - withAnimation { color = allColors[currentColorIndex] } + color = allColors[currentColorIndex] } } From ddba6f3f754c0fe0e91fae65c981dd640463920a Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Sat, 29 Jan 2022 16:59:50 +0000 Subject: [PATCH 20/36] Fixes --- Broadcast.xcodeproj/project.pbxproj | 8 ----- .../Extensions/Notification.extension.swift | 12 -------- Broadcast/Extensions/UIImage.extension.swift | 30 ------------------- Broadcast/Helper Views/ActionBarView.swift | 2 +- Broadcast/Helpers/TwitterClientManager.swift | 4 +-- 5 files changed, 3 insertions(+), 53 deletions(-) delete mode 100644 Broadcast/Extensions/Notification.extension.swift delete mode 100644 Broadcast/Extensions/UIImage.extension.swift diff --git a/Broadcast.xcodeproj/project.pbxproj b/Broadcast.xcodeproj/project.pbxproj index 1bbc1b2..7f9873a 100644 --- a/Broadcast.xcodeproj/project.pbxproj +++ b/Broadcast.xcodeproj/project.pbxproj @@ -18,14 +18,12 @@ 71800BF026905DB3009D11A1 /* EngagementCountersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71800BEF26905DB3009D11A1 /* EngagementCountersView.swift */; }; 71800BF226906721009D11A1 /* ActionBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71800BF126906721009D11A1 /* ActionBarView.swift */; }; 71800BF426906BC0009D11A1 /* DraftsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71800BF326906BC0009D11A1 /* DraftsListView.swift */; }; - 71800BF8269998BF009D11A1 /* UIImage.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71800BF7269998BF009D11A1 /* UIImage.extension.swift */; }; 71800BFD26999B1B009D11A1 /* DraftsModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 71800BFB26999B1B009D11A1 /* DraftsModel.xcdatamodeld */; }; 71800BFF26999BA6009D11A1 /* PersistanceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71800BFE26999BA6009D11A1 /* PersistanceController.swift */; }; 7188E6292687B0FE007CFD78 /* BroadcastApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E6282687B0FE007CFD78 /* BroadcastApp.swift */; }; 7188E62B2687B0FE007CFD78 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E62A2687B0FE007CFD78 /* ContentView.swift */; }; 7188E62D2687B0FF007CFD78 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7188E62C2687B0FF007CFD78 /* Assets.xcassets */; }; 7188E6302687B0FF007CFD78 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7188E62F2687B0FF007CFD78 /* Preview Assets.xcassets */; }; - 7188E63B2687B19D007CFD78 /* Notification.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E63A2687B19D007CFD78 /* Notification.extension.swift */; }; 7188E6472687B6C2007CFD78 /* SafariView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E6462687B6C2007CFD78 /* SafariView.swift */; }; 7188E6492687C0E0007CFD78 /* BroadcastButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E6482687C0E0007CFD78 /* BroadcastButtonStyle.swift */; }; 7188E64B2687C44D007CFD78 /* String.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E64A2687C44D007CFD78 /* String.extension.swift */; }; @@ -80,7 +78,6 @@ 71800BEF26905DB3009D11A1 /* EngagementCountersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementCountersView.swift; sourceTree = ""; }; 71800BF126906721009D11A1 /* ActionBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionBarView.swift; sourceTree = ""; }; 71800BF326906BC0009D11A1 /* DraftsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftsListView.swift; sourceTree = ""; }; - 71800BF7269998BF009D11A1 /* UIImage.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.extension.swift; sourceTree = ""; }; 71800BFC26999B1B009D11A1 /* DraftsModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = DraftsModel.xcdatamodel; sourceTree = ""; }; 71800BFE26999BA6009D11A1 /* PersistanceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistanceController.swift; sourceTree = ""; }; 7188E6252687B0FE007CFD78 /* Broadcast.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Broadcast.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -89,7 +86,6 @@ 7188E62C2687B0FF007CFD78 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 7188E62F2687B0FF007CFD78 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 7188E6312687B0FF007CFD78 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 7188E63A2687B19D007CFD78 /* Notification.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notification.extension.swift; sourceTree = ""; }; 7188E6462687B6C2007CFD78 /* SafariView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariView.swift; sourceTree = ""; }; 7188E6482687C0E0007CFD78 /* BroadcastButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastButtonStyle.swift; sourceTree = ""; }; 7188E64A2687C44D007CFD78 /* String.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.extension.swift; sourceTree = ""; }; @@ -200,10 +196,8 @@ 711F3FF9268F50C800605C89 /* Animation.extension.swift */, 719087CE26891C7F005B96CE /* Array.extension.swift */, 7188E6602688A01F007CFD78 /* Font.extension.swift */, - 7188E63A2687B19D007CFD78 /* Notification.extension.swift */, 7188E64A2687C44D007CFD78 /* String.extension.swift */, 7188E66B2688DB44007CFD78 /* UIApplication.extension.swift */, - 71800BF7269998BF009D11A1 /* UIImage.extension.swift */, 717041F626B703A600001360 /* TwitterClient+MockTweet.swift */, 7199AE7926B96D0D001DEB46 /* NSRegularExpression+Convenience.swift */, ); @@ -395,7 +389,6 @@ 7188E6612688A01F007CFD78 /* Font.extension.swift in Sources */, 7188E62B2687B0FE007CFD78 /* ContentView.swift in Sources */, 717041F526B7037300001360 /* TweetView.swift in Sources */, - 7188E63B2687B19D007CFD78 /* Notification.extension.swift in Sources */, 717041F326B6FFEA00001360 /* RepliesListView.swift in Sources */, 7188E6292687B0FE007CFD78 /* BroadcastApp.swift in Sources */, 71B8290C268D0AC6002AEE72 /* TwitterClientManager.swift in Sources */, @@ -420,7 +413,6 @@ 71800BF426906BC0009D11A1 /* DraftsListView.swift in Sources */, 71A9157927A59C9000706024 /* AsyncLocalMediaPreview.swift in Sources */, 71E36FFB2689EED40078D956 /* ShakeModifier.swift in Sources */, - 71800BF8269998BF009D11A1 /* UIImage.extension.swift in Sources */, 71D283F127A469EF00640B2A /* AttributeScopes+TwitterEntities.swift in Sources */, 719087CF26891C7F005B96CE /* Array.extension.swift in Sources */, 7188E64D2687C6A1007CFD78 /* Binding.extension.swift in Sources */, diff --git a/Broadcast/Extensions/Notification.extension.swift b/Broadcast/Extensions/Notification.extension.swift deleted file mode 100644 index 392d1b9..0000000 --- a/Broadcast/Extensions/Notification.extension.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// Notification.extension.swift -// Broadcast -// -// Created by Daniel Eden on 26/06/2021. -// - -import Foundation - -extension Notification.Name { - static let twitterCallback = Notification.Name(rawValue: "Twitter.CallbackNotification.Name") -} diff --git a/Broadcast/Extensions/UIImage.extension.swift b/Broadcast/Extensions/UIImage.extension.swift deleted file mode 100644 index ae31e1d..0000000 --- a/Broadcast/Extensions/UIImage.extension.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// UIImage.extension.swift -// Broadcast -// -// Created by Daniel Eden on 10/07/2021. -// - -import Foundation -import UIKit - -extension UIImage { - func fixOrientation(_ img: UIImage) -> UIImage { - if (img.imageOrientation == .up) { - return img - } - - UIGraphicsBeginImageContextWithOptions(img.size, false, img.scale) - let rect = CGRect(x: 0, y: 0, width: img.size.width, height: img.size.height) - img.draw(in: rect) - - let normalizedImage = UIGraphicsGetImageFromCurrentImageContext()! - UIGraphicsEndImageContext() - - return normalizedImage - } - - var fixedOrientation: UIImage { - return fixOrientation(self) - } -} diff --git a/Broadcast/Helper Views/ActionBarView.swift b/Broadcast/Helper Views/ActionBarView.swift index 762ead8..abb6dc1 100644 --- a/Broadcast/Helper Views/ActionBarView.swift +++ b/Broadcast/Helper Views/ActionBarView.swift @@ -33,7 +33,7 @@ struct ActionBarView: View { } private var moreMediaAllowed: Bool { - //if twitterClient.selectedMedia.contains(where: { $0.value?.mimeType == .mov || $0.value?.mimeType == .gif }) { return false } + if twitterClient.selectedMedia.contains(where: { $0.value.mediaMimeType == .mov || $0.value.mediaMimeType == .gif }) { return false } if twitterClient.selectedMedia.count == 4 { return false } return true } diff --git a/Broadcast/Helpers/TwitterClientManager.swift b/Broadcast/Helpers/TwitterClientManager.swift index b5c2d5c..fed3ae4 100644 --- a/Broadcast/Helpers/TwitterClientManager.swift +++ b/Broadcast/Helpers/TwitterClientManager.swift @@ -66,7 +66,7 @@ class TwitterClientManager: ObservableObject { @MainActor func signIn() async { - self.state = .busy() + self.updateState(.busy()) let client: Twift? = await withUnsafeContinuation { continuation in Twift.Authentication().requestUserCredentials(clientCredentials: ClientCredentials.credentials, @@ -83,7 +83,7 @@ class TwitterClientManager: ObservableObject { } await self.updateClient(client, animated: true) - self.state = .idle + self.updateState(.idle) } @MainActor From c5837a79b0c9da29cb7d8b1c633bb2804a4bcaf9 Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Sat, 29 Jan 2022 17:06:09 +0000 Subject: [PATCH 21/36] Housekeeping --- Broadcast.xcodeproj/project.pbxproj | 26 ++------ Broadcast/BroadcastApp.swift | 2 +- Broadcast/ContentView.swift | 2 +- .../Helper Views/EngagementCountersView.swift | 4 +- Broadcast/Helper Views/MentionBar.swift | 4 +- Broadcast/Helper Views/RemoteImage.swift | 65 ------------------- Broadcast/Helper Views/SafariView.swift | 53 --------------- Broadcast/Helper Views/VisualEffectView.swift | 15 ----- 8 files changed, 13 insertions(+), 158 deletions(-) delete mode 100644 Broadcast/Helper Views/RemoteImage.swift delete mode 100644 Broadcast/Helper Views/SafariView.swift delete mode 100644 Broadcast/Helper Views/VisualEffectView.swift diff --git a/Broadcast.xcodeproj/project.pbxproj b/Broadcast.xcodeproj/project.pbxproj index 7f9873a..8a08fff 100644 --- a/Broadcast.xcodeproj/project.pbxproj +++ b/Broadcast.xcodeproj/project.pbxproj @@ -24,7 +24,6 @@ 7188E62B2687B0FE007CFD78 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E62A2687B0FE007CFD78 /* ContentView.swift */; }; 7188E62D2687B0FF007CFD78 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7188E62C2687B0FF007CFD78 /* Assets.xcassets */; }; 7188E6302687B0FF007CFD78 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7188E62F2687B0FF007CFD78 /* Preview Assets.xcassets */; }; - 7188E6472687B6C2007CFD78 /* SafariView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E6462687B6C2007CFD78 /* SafariView.swift */; }; 7188E6492687C0E0007CFD78 /* BroadcastButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E6482687C0E0007CFD78 /* BroadcastButtonStyle.swift */; }; 7188E64B2687C44D007CFD78 /* String.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E64A2687C44D007CFD78 /* String.extension.swift */; }; 7188E64D2687C6A1007CFD78 /* Binding.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E64C2687C6A1007CFD78 /* Binding.extension.swift */; }; @@ -34,7 +33,6 @@ 7188E6612688A01F007CFD78 /* Font.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E6602688A01F007CFD78 /* Font.extension.swift */; }; 7188E6632688A0FC007CFD78 /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E6622688A0FC007CFD78 /* WelcomeView.swift */; }; 7188E6652688A436007CFD78 /* ThemeHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E6642688A436007CFD78 /* ThemeHelper.swift */; }; - 7188E6672688B99E007CFD78 /* VisualEffectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E6662688B99E007CFD78 /* VisualEffectView.swift */; }; 7188E66A2688D7BA007CFD78 /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 7188E6692688D7BA007CFD78 /* Introspect */; }; 7188E66C2688DB44007CFD78 /* UIApplication.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E66B2688DB44007CFD78 /* UIApplication.extension.swift */; }; 719087CD26891586005B96CE /* TwitterText in Frameworks */ = {isa = PBXBuildFile; productRef = 719087CC26891586005B96CE /* TwitterText */; }; @@ -46,7 +44,6 @@ 71A1088A268B073B007E1FFB /* Haptics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71A10889268B073B007E1FFB /* Haptics.swift */; }; 71A6A264278C73AD00BF2387 /* TwitterAPI-Info.example.plist in Resources */ = {isa = PBXBuildFile; fileRef = 71A6A263278C73AD00BF2387 /* TwitterAPI-Info.example.plist */; }; 71A9157927A59C9000706024 /* AsyncLocalMediaPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71A9157827A59C9000706024 /* AsyncLocalMediaPreview.swift */; }; - 71AA4AC6268A032400B7B577 /* RemoteImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71AA4AC5268A032400B7B577 /* RemoteImage.swift */; }; 71B8290C268D0AC6002AEE72 /* TwitterClientManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71B8290B268D0AC6002AEE72 /* TwitterClientManager.swift */; }; 71B82910268F2195002AEE72 /* LastTweetReplyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71B8290F268F2195002AEE72 /* LastTweetReplyView.swift */; }; 71BBAAE9268CF1BD004048A0 /* ComposerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71BBAAE8268CF1BD004048A0 /* ComposerView.swift */; }; @@ -86,7 +83,6 @@ 7188E62C2687B0FF007CFD78 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 7188E62F2687B0FF007CFD78 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 7188E6312687B0FF007CFD78 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 7188E6462687B6C2007CFD78 /* SafariView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariView.swift; sourceTree = ""; }; 7188E6482687C0E0007CFD78 /* BroadcastButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastButtonStyle.swift; sourceTree = ""; }; 7188E64A2687C44D007CFD78 /* String.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.extension.swift; sourceTree = ""; }; 7188E64C2687C6A1007CFD78 /* Binding.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Binding.extension.swift; sourceTree = ""; }; @@ -95,7 +91,6 @@ 7188E6602688A01F007CFD78 /* Font.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Font.extension.swift; sourceTree = ""; }; 7188E6622688A0FC007CFD78 /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; }; 7188E6642688A436007CFD78 /* ThemeHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeHelper.swift; sourceTree = ""; }; - 7188E6662688B99E007CFD78 /* VisualEffectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectView.swift; sourceTree = ""; }; 7188E66B2688DB44007CFD78 /* UIApplication.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplication.extension.swift; sourceTree = ""; }; 719087CE26891C7F005B96CE /* Array.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.extension.swift; sourceTree = ""; }; 7199AE7926B96D0D001DEB46 /* NSRegularExpression+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSRegularExpression+Convenience.swift"; sourceTree = ""; }; @@ -105,7 +100,6 @@ 71A10889268B073B007E1FFB /* Haptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Haptics.swift; sourceTree = ""; }; 71A6A263278C73AD00BF2387 /* TwitterAPI-Info.example.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "TwitterAPI-Info.example.plist"; sourceTree = ""; }; 71A9157827A59C9000706024 /* AsyncLocalMediaPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncLocalMediaPreview.swift; sourceTree = ""; }; - 71AA4AC5268A032400B7B577 /* RemoteImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImage.swift; sourceTree = ""; }; 71B8290B268D0AC6002AEE72 /* TwitterClientManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwitterClientManager.swift; sourceTree = ""; }; 71B8290F268F2195002AEE72 /* LastTweetReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastTweetReplyView.swift; sourceTree = ""; }; 71BBAAE8268CF1BD004048A0 /* ComposerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerView.swift; sourceTree = ""; }; @@ -207,15 +201,15 @@ 7188E6402687B431007CFD78 /* Helpers */ = { isa = PBXGroup; children = ( + 71D283F027A469EF00640B2A /* AttributeScopes+TwitterEntities.swift */, 7188E64C2687C6A1007CFD78 /* Binding.extension.swift */, + 7199AE7F26B9892E001DEB46 /* Debouncer.swift */, 71A10889268B073B007E1FFB /* Haptics.swift */, + 71800BFE26999BA6009D11A1 /* PersistanceController.swift */, 71E36FFA2689EED40078D956 /* ShakeModifier.swift */, + 715AAE0B26C9589A002BCEA1 /* TestUtils.swift */, 7188E6642688A436007CFD78 /* ThemeHelper.swift */, 71B8290B268D0AC6002AEE72 /* TwitterClientManager.swift */, - 71800BFE26999BA6009D11A1 /* PersistanceController.swift */, - 7199AE7F26B9892E001DEB46 /* Debouncer.swift */, - 715AAE0B26C9589A002BCEA1 /* TestUtils.swift */, - 71D283F027A469EF00640B2A /* AttributeScopes+TwitterEntities.swift */, ); path = Helpers; sourceTree = ""; @@ -224,22 +218,19 @@ isa = PBXGroup; children = ( 71800BF126906721009D11A1 /* ActionBarView.swift */, + 71A9157827A59C9000706024 /* AsyncLocalMediaPreview.swift */, 7188E6522687D16C007CFD78 /* AttachmentThumbnail.swift */, 7188E6482687C0E0007CFD78 /* BroadcastButtonStyle.swift */, 71BBAAE8268CF1BD004048A0 /* ComposerView.swift */, 71800BF326906BC0009D11A1 /* DraftsListView.swift */, 71800BEF26905DB3009D11A1 /* EngagementCountersView.swift */, 71B8290F268F2195002AEE72 /* LastTweetReplyView.swift */, + 7199AE7D26B973B2001DEB46 /* MentionBar.swift */, + 7101073526C810AC00A713A5 /* NullStateView.swift */, 7188E64E2687CCD0007CFD78 /* PhotoPicker.swift */, - 71AA4AC5268A032400B7B577 /* RemoteImage.swift */, - 7188E6462687B6C2007CFD78 /* SafariView.swift */, - 7188E6662688B99E007CFD78 /* VisualEffectView.swift */, 717041F226B6FFEA00001360 /* RepliesListView.swift */, 717041F426B7037300001360 /* TweetView.swift */, 7199AE7B26B97327001DEB46 /* UserView.swift */, - 7199AE7D26B973B2001DEB46 /* MentionBar.swift */, - 7101073526C810AC00A713A5 /* NullStateView.swift */, - 71A9157827A59C9000706024 /* AsyncLocalMediaPreview.swift */, ); path = "Helper Views"; sourceTree = ""; @@ -395,7 +386,6 @@ 7188E6632688A0FC007CFD78 /* WelcomeView.swift in Sources */, 7199AE7A26B96D0D001DEB46 /* NSRegularExpression+Convenience.swift in Sources */, 717041F726B703A600001360 /* TwitterClient+MockTweet.swift in Sources */, - 7188E6472687B6C2007CFD78 /* SafariView.swift in Sources */, 7188E6492687C0E0007CFD78 /* BroadcastButtonStyle.swift in Sources */, 71800BF026905DB3009D11A1 /* EngagementCountersView.swift in Sources */, 7199AE7C26B97327001DEB46 /* UserView.swift in Sources */, @@ -418,8 +408,6 @@ 7188E64D2687C6A1007CFD78 /* Binding.extension.swift in Sources */, 7199AE8026B9892E001DEB46 /* Debouncer.swift in Sources */, 711F3FFA268F50C800605C89 /* Animation.extension.swift in Sources */, - 71AA4AC6268A032400B7B577 /* RemoteImage.swift in Sources */, - 7188E6672688B99E007CFD78 /* VisualEffectView.swift in Sources */, 71A1088A268B073B007E1FFB /* Haptics.swift in Sources */, 71BBAAE9268CF1BD004048A0 /* ComposerView.swift in Sources */, 715AAE0C26C9589A002BCEA1 /* TestUtils.swift in Sources */, diff --git a/Broadcast/BroadcastApp.swift b/Broadcast/BroadcastApp.swift index 8be644b..3da85a7 100644 --- a/Broadcast/BroadcastApp.swift +++ b/Broadcast/BroadcastApp.swift @@ -27,7 +27,7 @@ struct BroadcastApp: App { persistenceController.save() } - VisualEffectView(effect: UIBlurEffect(style: .regular)) + Color.clear.background(.regularMaterial) .frame(height: geom.safeAreaInsets.top) .ignoresSafeArea(.all, edges: .top) } diff --git a/Broadcast/ContentView.swift b/Broadcast/ContentView.swift index a9b9176..73440dc 100644 --- a/Broadcast/ContentView.swift +++ b/Broadcast/ContentView.swift @@ -95,7 +95,7 @@ struct ContentView: View { } .padding() .background( - VisualEffectView(effect: UIBlurEffect(style: .regular)) + Color.clear.background(.regularMaterial) .ignoresSafeArea() .opacity(twitterClient.user == nil ? 0 : 1) ) diff --git a/Broadcast/Helper Views/EngagementCountersView.swift b/Broadcast/Helper Views/EngagementCountersView.swift index bdb96ec..7da8a7f 100644 --- a/Broadcast/Helper Views/EngagementCountersView.swift +++ b/Broadcast/Helper Views/EngagementCountersView.swift @@ -15,9 +15,9 @@ struct EngagementCountersView: View { let replyCount = tweet.publicMetrics?.replyCount ?? 0 switch replyCount { case 0: - return "No replies" + return "No Replies" case 1: - return "1 Reply" + return "\(replyCount) Reply" default: return "\(replyCount) Replies" } diff --git a/Broadcast/Helper Views/MentionBar.swift b/Broadcast/Helper Views/MentionBar.swift index fb911fd..793e6c7 100644 --- a/Broadcast/Helper Views/MentionBar.swift +++ b/Broadcast/Helper Views/MentionBar.swift @@ -18,7 +18,7 @@ struct MentionBar: View { ForEach(users, id: \.id) { user in UserView(user: user) .padding(8) - .background(VisualEffectView(effect: UIBlurEffect(style: .systemMaterial))) + .background(.regularMaterial) .cornerRadius(6) .shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 6) .onTapGesture { @@ -27,6 +27,6 @@ struct MentionBar: View { } }.padding(12) } - .background(VisualEffectView(effect: UIBlurEffect(style: .prominent))) + .background(.thinMaterial) } } diff --git a/Broadcast/Helper Views/RemoteImage.swift b/Broadcast/Helper Views/RemoteImage.swift deleted file mode 100644 index ce56fbc..0000000 --- a/Broadcast/Helper Views/RemoteImage.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// RemoteImage.swift -// Broadcast -// -// Created by Daniel Eden on 28/06/2021. -// - -import SwiftUI -import Combine -import UIKit - -class ImageLoader: ObservableObject { - @Published var image: UIImage? - private let url: URL - - init(url: URL) { - self.url = url - } - - deinit { - cancel() - } - - private var cancellable: AnyCancellable? - - func load() { - cancellable = URLSession.shared.dataTaskPublisher(for: url) - .map { UIImage(data: $0.data) } - .replaceError(with: nil) - .receive(on: DispatchQueue.main) - .sink { [weak self] image in - withAnimation { self?.image = image } - } - } - - func cancel() { - cancellable?.cancel() - } -} - -struct RemoteImage: View { - @StateObject private var loader: ImageLoader - private let placeholder: Placeholder - - init(url: URL, @ViewBuilder placeholder: () -> Placeholder) { - self.placeholder = placeholder() - _loader = StateObject(wrappedValue: ImageLoader(url: url)) - } - - var body: some View { - content - .onAppear(perform: loader.load) - } - - private var content: some View { - Group { - if loader.image != nil { - Image(uiImage: loader.image!) - .resizable() - } else { - placeholder - } - } - } -} diff --git a/Broadcast/Helper Views/SafariView.swift b/Broadcast/Helper Views/SafariView.swift deleted file mode 100644 index 0752050..0000000 --- a/Broadcast/Helper Views/SafariView.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// SafariView.swift -// Broadcast -// -// Created by Daniel Eden on 26/06/2021. -// - -import SwiftUI -import SafariServices - -struct SafariView: UIViewControllerRepresentable { - class SafariViewControllerWrapper: UIViewController { - private var safariViewController: SFSafariViewController? - - var url: URL? { - didSet { - if let safariViewController = safariViewController { - safariViewController.willMove(toParent: self) - safariViewController.view.removeFromSuperview() - safariViewController.removeFromParent() - self.safariViewController = nil - } - - guard let url = url else { return } - - let newSafariViewController = SFSafariViewController(url: url) - addChild(newSafariViewController) - newSafariViewController.view.frame = view.frame - view.addSubview(newSafariViewController.view) - newSafariViewController.didMove(toParent: self) - self.safariViewController = newSafariViewController - } - } - - override func viewDidLoad() { - super.viewDidLoad() - self.url = nil - } - } - - typealias UIViewControllerType = SafariViewControllerWrapper - - @Binding var url: URL? - - func makeUIViewController(context: UIViewControllerRepresentableContext) -> SafariViewControllerWrapper { - return SafariViewControllerWrapper() - } - - func updateUIViewController(_ safariViewControllerWrapper: SafariViewControllerWrapper, - context: UIViewControllerRepresentableContext) { - safariViewControllerWrapper.url = url - } -} diff --git a/Broadcast/Helper Views/VisualEffectView.swift b/Broadcast/Helper Views/VisualEffectView.swift deleted file mode 100644 index 3d4179b..0000000 --- a/Broadcast/Helper Views/VisualEffectView.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// VisualEffectView.swift -// Broadcast -// -// Created by Daniel Eden on 27/06/2021. -// - -import Foundation -import SwiftUI - -struct VisualEffectView: UIViewRepresentable { - var effect: UIVisualEffect? - func makeUIView(context: UIViewRepresentableContext) -> UIVisualEffectView { UIVisualEffectView() } - func updateUIView(_ uiView: UIVisualEffectView, context: UIViewRepresentableContext) { uiView.effect = effect } -} From 3f8489c6dc5d6d47eb718995c5cc2d7d3ea9d496 Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Sat, 29 Jan 2022 17:13:58 +0000 Subject: [PATCH 22/36] Housekeeping --- Broadcast/Helper Views/ActionBarView.swift | 1 + Broadcast/Helper Views/AsyncLocalMediaPreview.swift | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/Broadcast/Helper Views/ActionBarView.swift b/Broadcast/Helper Views/ActionBarView.swift index abb6dc1..c3d445e 100644 --- a/Broadcast/Helper Views/ActionBarView.swift +++ b/Broadcast/Helper Views/ActionBarView.swift @@ -43,6 +43,7 @@ struct ActionBarView: View { .disabled(twitterClient.state == .busy()) .sheet(isPresented: $photoPickerIsPresented) { ImagePicker(configuration: pickerConfig, selection: $twitterClient.selectedMedia) + .ignoresSafeArea() } .onLongPressGesture { ThemeHelper.shared.rotateTheme() diff --git a/Broadcast/Helper Views/AsyncLocalMediaPreview.swift b/Broadcast/Helper Views/AsyncLocalMediaPreview.swift index 2f8c07d..abe56e7 100644 --- a/Broadcast/Helper Views/AsyncLocalMediaPreview.swift +++ b/Broadcast/Helper Views/AsyncLocalMediaPreview.swift @@ -14,6 +14,15 @@ struct AsyncLocalMediaPreview: View { case failed case loading case loadingWithProgress(_ progress: Progress) + + var finished: Bool { + switch self { + case .loaded(_), .failed: + return true + default: + return false + } + } } var assetId: String var asset: PHPickerResult From 8d378ed1677b62ed54cc2a54672d7c3be6431d90 Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Sat, 29 Jan 2022 18:02:06 +0000 Subject: [PATCH 23/36] Housekeeping --- .../Helper Views/AsyncLocalMediaPreview.swift | 1 + Broadcast/Helpers/TwitterClientManager.swift | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/Broadcast/Helper Views/AsyncLocalMediaPreview.swift b/Broadcast/Helper Views/AsyncLocalMediaPreview.swift index abe56e7..1a970d6 100644 --- a/Broadcast/Helper Views/AsyncLocalMediaPreview.swift +++ b/Broadcast/Helper Views/AsyncLocalMediaPreview.swift @@ -46,6 +46,7 @@ struct AsyncLocalMediaPreview: View { .padding() case .loadingWithProgress(let progress): ProgressView(progress) + .progressViewStyle(.circular) .padding() } } diff --git a/Broadcast/Helpers/TwitterClientManager.swift b/Broadcast/Helpers/TwitterClientManager.swift index fed3ae4..584f727 100644 --- a/Broadcast/Helpers/TwitterClientManager.swift +++ b/Broadcast/Helpers/TwitterClientManager.swift @@ -17,6 +17,29 @@ import PhotosUI let typeaheadToken = "AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA" +typealias AuthenticatedIds = [User.ID] + +extension AuthenticatedIds: RawRepresentable { + public typealias RawValue = String + public init?(rawValue: RawValue) { + guard let data = rawValue.data(using: .utf8), + let result = try? JSONDecoder().decode(AuthenticatedIds.self, from: data) else { + return nil + } + + self = result + } + + public var rawValue: RawValue { + guard let encoded = try? JSONEncoder().encode(self), + let result = String(data: encoded, encoding: .utf8) else { + return "[]" + } + + return result + } +} + class TwitterClientManager: ObservableObject { let draftsStore = PersistanceController.shared @Published var user: User? From 93ef086ac94074b6ee6dd69077dd5f735ab29024 Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Sat, 29 Jan 2022 18:03:54 +0000 Subject: [PATCH 24/36] Update allowed alt text to include png --- Broadcast/Helper Views/PhotoPicker.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Broadcast/Helper Views/PhotoPicker.swift b/Broadcast/Helper Views/PhotoPicker.swift index a63c5dc..f506d65 100644 --- a/Broadcast/Helper Views/PhotoPicker.swift +++ b/Broadcast/Helper Views/PhotoPicker.swift @@ -26,7 +26,7 @@ extension PHPickerResult { var allowsAltText: Bool { guard let mimeType = mediaMimeType else { return false } switch mimeType { - case .gif, .jpeg: return true + case .gif, .jpeg, .png: return true default: return false } } From 414926b9194a17c8465661c52b1f21ad06734869 Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Sat, 29 Jan 2022 18:35:24 +0000 Subject: [PATCH 25/36] Housekeeping --- .../Helper Views/AsyncLocalMediaPreview.swift | 3 +-- Broadcast/Helper Views/PhotoPicker.swift | 17 ++++++++++------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/Broadcast/Helper Views/AsyncLocalMediaPreview.swift b/Broadcast/Helper Views/AsyncLocalMediaPreview.swift index 1a970d6..ab16e4e 100644 --- a/Broadcast/Helper Views/AsyncLocalMediaPreview.swift +++ b/Broadcast/Helper Views/AsyncLocalMediaPreview.swift @@ -45,8 +45,7 @@ struct AsyncLocalMediaPreview: View { ProgressView() .padding() case .loadingWithProgress(let progress): - ProgressView(progress) - .progressViewStyle(.circular) + ProgressView(value: progress.fractionCompleted) .padding() } } diff --git a/Broadcast/Helper Views/PhotoPicker.swift b/Broadcast/Helper Views/PhotoPicker.swift index f506d65..7297c09 100644 --- a/Broadcast/Helper Views/PhotoPicker.swift +++ b/Broadcast/Helper Views/PhotoPicker.swift @@ -11,16 +11,19 @@ import SwiftUI import Twift extension PHPickerResult { - var mediaType: UTType? { - guard let registeredTypeIdentifier = self.itemProvider.registeredTypeIdentifiers.first else { - return nil - } - return UTType(registeredTypeIdentifier) + var mediaTypes: [UTType] { + return self.itemProvider.registeredTypeIdentifiers.compactMap { UTType($0) } } var mediaMimeType: Media.MimeType? { - guard let utTypeMimeType = mediaType?.preferredMIMEType else { return nil } - return .init(rawValue: utTypeMimeType) + return mediaTypes.reduce(nil, { partialResult, current in + if partialResult == nil, + let mimeType = current.preferredMIMEType { + return Media.MimeType(rawValue: mimeType) + } else { + return nil + } + }) } var allowsAltText: Bool { From 8ee816d956bbf1ed7e04a7025f8efe6dea976573 Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Sun, 30 Jan 2022 12:46:06 +0000 Subject: [PATCH 26/36] Design fixes --- Broadcast/BroadcastApp.swift | 4 +- Broadcast/ContentView.swift | 43 +++++++++---------- .../Helper Views/AsyncLocalMediaPreview.swift | 1 - .../Helper Views/AttachmentThumbnail.swift | 1 + Broadcast/Helper Views/ComposerView.swift | 5 ++- 5 files changed, 26 insertions(+), 28 deletions(-) diff --git a/Broadcast/BroadcastApp.swift b/Broadcast/BroadcastApp.swift index 3da85a7..7e0f54b 100644 --- a/Broadcast/BroadcastApp.swift +++ b/Broadcast/BroadcastApp.swift @@ -27,9 +27,9 @@ struct BroadcastApp: App { persistenceController.save() } - Color.clear.background(.regularMaterial) + Color.clear.background(Material.bar) .frame(height: geom.safeAreaInsets.top) - .ignoresSafeArea(.all, edges: .top) + .ignoresSafeArea(.container, edges: .top) } } } diff --git a/Broadcast/ContentView.swift b/Broadcast/ContentView.swift index 73440dc..c988d20 100644 --- a/Broadcast/ContentView.swift +++ b/Broadcast/ContentView.swift @@ -11,7 +11,7 @@ import TwitterText import Twift struct ContentView: View { - @ScaledMetric private var captionSize: CGFloat = 14 + @ScaledMetric private var captionSize: CGFloat = 20 @ScaledMetric private var bottomPadding: CGFloat = 80 @ScaledMetric private var replyBoxLimit: CGFloat = 96 @@ -33,7 +33,7 @@ struct ContentView: View { GeometryReader { geom in ZStack(alignment: .bottom) { ScrollView { - VStack(spacing: 8) { + VStack(spacing: captionSize / 2) { if replying, let lastTweet = twitterClient.lastTweet { LastTweetReplyView(lastTweet: lastTweet) .background(GeometryReader { geometry in @@ -76,30 +76,27 @@ struct ContentView: View { WelcomeView() } } - .padding() - .padding(.bottom, bottomPadding) + .padding(.top, captionSize) + .padding(.horizontal) .frame(maxWidth: geom.size.width) } - - VStack { - if twitterClient.user != nil { - ActionBarView(replying: $replying) - } else { - Button(action: { Task { await twitterClient.signIn() } }) { - Label("Sign In With Twitter", image: "twitter.fill") - .font(.broadcastHeadline) + .safeAreaInset(edge: .bottom, content: { + Group { + if twitterClient.user != nil { + ActionBarView(replying: $replying) + } else { + Button(action: { Task { await twitterClient.signIn() } }) { + Label("Sign In With Twitter", image: "twitter.fill") + .font(.broadcastHeadline) + } } - .buttonStyle(BroadcastButtonStyle()) - .accessibilityIdentifier("loginButton") } - } - .padding() - .background( - Color.clear.background(.regularMaterial) - .ignoresSafeArea() - .opacity(twitterClient.user == nil ? 0 : 1) - ) - .gesture(DragGesture().onEnded({ _ in UIApplication.shared.endEditing() })) + .buttonStyle(BroadcastButtonStyle()) + .accessibilityIdentifier("loginButton") + .padding() + .background(.ultraThinMaterial) + .gesture(DragGesture().onEnded({ _ in UIApplication.shared.endEditing() })) + }) } .sheet(isPresented: $repliesSheetIsPresented) { RepliesListView(tweet: twitterClient.lastTweet) @@ -111,7 +108,7 @@ struct ContentView: View { UITextView.appearance().backgroundColor = .clear } .onPreferenceChange(ReplyBoxSizePreferenceKey.self) { newValue in - withAnimation(.springAnimation) { replyBoxHeight = newValue + 8 } + withAnimation(.springAnimation) { replyBoxHeight = newValue + (captionSize / 2) } } .overlay { if twitterClient.state == .initializing { diff --git a/Broadcast/Helper Views/AsyncLocalMediaPreview.swift b/Broadcast/Helper Views/AsyncLocalMediaPreview.swift index ab16e4e..7264195 100644 --- a/Broadcast/Helper Views/AsyncLocalMediaPreview.swift +++ b/Broadcast/Helper Views/AsyncLocalMediaPreview.swift @@ -49,7 +49,6 @@ struct AsyncLocalMediaPreview: View { .padding() } } - .transition(.opacity) .frame(maxWidth: .infinity, minHeight: 48) .background(.thinMaterial) .task { await loadPreview() } diff --git a/Broadcast/Helper Views/AttachmentThumbnail.swift b/Broadcast/Helper Views/AttachmentThumbnail.swift index c584647..5eaaed9 100644 --- a/Broadcast/Helper Views/AttachmentThumbnail.swift +++ b/Broadcast/Helper Views/AttachmentThumbnail.swift @@ -55,6 +55,7 @@ struct AttachmentThumbnail: View { } } } + .transition(.scale) } func itemHasAltText(_ id: String) -> Bool { diff --git a/Broadcast/Helper Views/ComposerView.swift b/Broadcast/Helper Views/ComposerView.swift index 28467f8..ea75a6a 100644 --- a/Broadcast/Helper Views/ComposerView.swift +++ b/Broadcast/Helper Views/ComposerView.swift @@ -116,8 +116,9 @@ struct ComposerView: View { }.transition(.scale) Divider() + .padding(.bottom, verticalPadding) - HStack(alignment: .top) { + HStack(alignment: .firstTextBaseline) { Menu { Button(action: { twitterClient.saveDraft() }) { Label("Save Draft", systemImage: "square.and.pencil") @@ -141,7 +142,7 @@ struct ComposerView: View { } .disabled(twitterClient.state == .busy()) .padding() - .background(Color(.tertiarySystemGroupedBackground)) + .background(.thinMaterial) .onShake { rotatePlaceholder() Haptics.shared.sendStandardFeedback(feedbackType: .success) From 308333c49a91e9b8fa09b3841ffd085fa4f5e80b Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Sun, 30 Jan 2022 16:04:31 +0000 Subject: [PATCH 27/36] Fix tests --- Broadcast.xcodeproj/project.pbxproj | 4 ++ Broadcast/ContentView.swift | 2 +- Broadcast/Helper Views/ActionBarView.swift | 2 +- Broadcast/Helper Views/ComposerView.swift | 15 ++----- Broadcast/Helper Views/TweetView.swift | 10 +---- Broadcast/Helper Views/UserAvatar.swift | 33 ++++++++++++++++ Broadcast/Helper Views/UserView.swift | 10 +---- Broadcast/Helpers/TwitterClientManager.swift | 41 +++++++++++++------- Broadcast/TwitterAPI-Info.example.plist | 10 +++-- BroadcastUITests/BroadcastUITests.swift | 12 +++--- 10 files changed, 81 insertions(+), 58 deletions(-) create mode 100644 Broadcast/Helper Views/UserAvatar.swift diff --git a/Broadcast.xcodeproj/project.pbxproj b/Broadcast.xcodeproj/project.pbxproj index 8a08fff..4c7bf34 100644 --- a/Broadcast.xcodeproj/project.pbxproj +++ b/Broadcast.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 7101073626C810AC00A713A5 /* NullStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7101073526C810AC00A713A5 /* NullStateView.swift */; }; 711EF99426C959A700FD8A9F /* BroadcastUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711EF99326C959A700FD8A9F /* BroadcastUITests.swift */; }; 711F3FFA268F50C800605C89 /* Animation.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711F3FF9268F50C800605C89 /* Animation.extension.swift */; }; + 713E16EA27A6EAA900314B44 /* UserAvatar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 713E16E927A6EAA900314B44 /* UserAvatar.swift */; }; 714782D5278D97AB00942618 /* Twift in Frameworks */ = {isa = PBXBuildFile; productRef = 714782D4278D97AB00942618 /* Twift */; }; 715AAE0C26C9589A002BCEA1 /* TestUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 715AAE0B26C9589A002BCEA1 /* TestUtils.swift */; }; 717041F326B6FFEA00001360 /* RepliesListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 717041F226B6FFEA00001360 /* RepliesListView.swift */; }; @@ -68,6 +69,7 @@ 711EF99326C959A700FD8A9F /* BroadcastUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastUITests.swift; sourceTree = ""; }; 711EF99526C959A700FD8A9F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 711F3FF9268F50C800605C89 /* Animation.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Animation.extension.swift; sourceTree = ""; }; + 713E16E927A6EAA900314B44 /* UserAvatar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAvatar.swift; sourceTree = ""; }; 715AAE0B26C9589A002BCEA1 /* TestUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestUtils.swift; sourceTree = ""; }; 717041F226B6FFEA00001360 /* RepliesListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepliesListView.swift; sourceTree = ""; }; 717041F426B7037300001360 /* TweetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TweetView.swift; sourceTree = ""; }; @@ -231,6 +233,7 @@ 717041F226B6FFEA00001360 /* RepliesListView.swift */, 717041F426B7037300001360 /* TweetView.swift */, 7199AE7B26B97327001DEB46 /* UserView.swift */, + 713E16E927A6EAA900314B44 /* UserAvatar.swift */, ); path = "Helper Views"; sourceTree = ""; @@ -410,6 +413,7 @@ 711F3FFA268F50C800605C89 /* Animation.extension.swift in Sources */, 71A1088A268B073B007E1FFB /* Haptics.swift in Sources */, 71BBAAE9268CF1BD004048A0 /* ComposerView.swift in Sources */, + 713E16EA27A6EAA900314B44 /* UserAvatar.swift in Sources */, 715AAE0C26C9589A002BCEA1 /* TestUtils.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Broadcast/ContentView.swift b/Broadcast/ContentView.swift index c988d20..60699ef 100644 --- a/Broadcast/ContentView.swift +++ b/Broadcast/ContentView.swift @@ -89,10 +89,10 @@ struct ContentView: View { Label("Sign In With Twitter", image: "twitter.fill") .font(.broadcastHeadline) } + .accessibilityIdentifier("loginButton") } } .buttonStyle(BroadcastButtonStyle()) - .accessibilityIdentifier("loginButton") .padding() .background(.ultraThinMaterial) .gesture(DragGesture().onEnded({ _ in UIApplication.shared.endEditing() })) diff --git a/Broadcast/Helper Views/ActionBarView.swift b/Broadcast/Helper Views/ActionBarView.swift index c3d445e..f929b62 100644 --- a/Broadcast/Helper Views/ActionBarView.swift +++ b/Broadcast/Helper Views/ActionBarView.swift @@ -99,6 +99,7 @@ struct ActionBarView: View { ) ) } + .accessibilityIdentifier("sendTweetButton") .buttonStyle( BroadcastButtonStyle( prominence: !replying ? .primary : .secondary, @@ -107,7 +108,6 @@ struct ActionBarView: View { ) ) .disabled(!replying && !twitterClient.draftIsValid()) - .accessibilityIdentifier("sendTweetButton") Button(action: { photoPickerIsPresented.toggle() diff --git a/Broadcast/Helper Views/ComposerView.swift b/Broadcast/Helper Views/ComposerView.swift index ea75a6a..9e89c29 100644 --- a/Broadcast/Helper Views/ComposerView.swift +++ b/Broadcast/Helper Views/ComposerView.swift @@ -82,20 +82,11 @@ struct ComposerView: View { Section { Button(role: .destructive, action: {twitterClient.signOut()}) { Label("Sign Out", systemImage: "person.badge.minus") - } + }.accessibilityIdentifier("logoutButton") } } label: { - AsyncImage(url: twitterClient.user?.profileImageUrlLarger) { image in - image - .resizable() - .aspectRatio(contentMode: .fill) - .cornerRadius(36) - } placeholder: { - ProgressView() - } - .background(.regularMaterial) - .frame(width: 36, height: 36) - } + UserAvatar(avatarUrl: twitterClient.user?.profileImageUrlLarger) + }.accessibilityIdentifier("profilePhotoButton") ZStack(alignment: .topLeading) { Text(tweetText.isEmpty ? placeholder : tweetText) diff --git a/Broadcast/Helper Views/TweetView.swift b/Broadcast/Helper Views/TweetView.swift index 69a4a09..267e82c 100644 --- a/Broadcast/Helper Views/TweetView.swift +++ b/Broadcast/Helper Views/TweetView.swift @@ -25,15 +25,7 @@ struct TweetView: View { var body: some View { HStack(alignment: .top) { - AsyncImage(url: author.profileImageUrl) { image in - image - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: avatarSize, height: avatarSize) - .cornerRadius(36) - } placeholder: { - ProgressView() - } + UserAvatar(avatarUrl: author.profileImageUrlLarger) VStack(alignment: .leading, spacing: 4) { HStack { diff --git a/Broadcast/Helper Views/UserAvatar.swift b/Broadcast/Helper Views/UserAvatar.swift new file mode 100644 index 0000000..8652848 --- /dev/null +++ b/Broadcast/Helper Views/UserAvatar.swift @@ -0,0 +1,33 @@ +// +// UserAvatar.swift +// Broadcast +// +// Created by Daniel Eden on 30/01/2022. +// + +import SwiftUI + +struct UserAvatar: View { + var avatarUrl: URL? + @ScaledMetric var size = 36 + var body: some View { + AsyncImage(url: avatarUrl) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + .cornerRadius(size) + .overlay(Circle().stroke(Color.primary.opacity(0.1), lineWidth: 1)) + } placeholder: { + ProgressView() + } + .background(.regularMaterial) + .clipShape(Circle()) + .frame(width: size, height: size) + } +} + +struct UserAvatar_Previews: PreviewProvider { + static var previews: some View { + UserAvatar() + } +} diff --git a/Broadcast/Helper Views/UserView.swift b/Broadcast/Helper Views/UserView.swift index 4c37ee9..a12d816 100644 --- a/Broadcast/Helper Views/UserView.swift +++ b/Broadcast/Helper Views/UserView.swift @@ -14,15 +14,7 @@ struct UserView: View { var user: User var body: some View { HStack { - AsyncImage(url: user.profileImageUrl) { image in - image - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: avatarSize, height: avatarSize) - .cornerRadius(36) - } placeholder: { - ProgressView() - } + UserAvatar(avatarUrl: user.profileImageUrlLarger, size: avatarSize) VStack(alignment: .leading) { if let name = user.name { diff --git a/Broadcast/Helpers/TwitterClientManager.swift b/Broadcast/Helpers/TwitterClientManager.swift index 584f727..72be937 100644 --- a/Broadcast/Helpers/TwitterClientManager.swift +++ b/Broadcast/Helpers/TwitterClientManager.swift @@ -127,10 +127,9 @@ class TwitterClientManager: ObservableObject { } func retreiveCredentials() -> OAuthCredentials? { - // TODO: Fix test environment - // if isTestEnvironment { - // return .init(queryString: ClientCredentials.__authQueryString) - // } + if isTestEnvironment { + return ClientCredentials.userCredentials + } guard let data = KeychainWrapper.standard.data(forKey: "broadcast-credentials") else { return nil } @@ -316,7 +315,7 @@ fileprivate struct V1User: Codable { /* MARK: Drafts */ extension TwitterClientManager { public func draftIsValid() -> Bool { - if let text = draft.text, !text.isEmpty { + if let text = draft.text, !text.isEmpty && !text.isBlank { return TwitterText.remainingCharacterCount(text: text) >= 0 } else if !selectedMedia.isEmpty { return true @@ -386,16 +385,16 @@ extension TwitterClientManager { } static var apiKey: String { - guard let value = plist?.object(forKey: "API_KEY") as? String else { - fatalError("Couldn't find key 'API_KEY' in 'TwitterAPI-Info.plist'.") + guard let value = plist?.object(forKey: "TWITTER_CONSUMER_KEY") as? String else { + fatalError("Couldn't find key 'TWITTER_CONSUMER_KEY' in 'TwitterAPI-Info.plist'.") } return value } static var apiSecret: String { - guard let value = plist?.object(forKey: "API_SECRET") as? String else { - fatalError("Couldn't find key 'API_KEY' in 'TwitterAPI-Info.plist'.") + guard let value = plist?.object(forKey: "TWITTER_CONSUMER_SECRET") as? String else { + fatalError("Couldn't find key 'TWITTER_CONSUMER_SECRET' in 'TwitterAPI-Info.plist'.") } return value @@ -405,17 +404,29 @@ extension TwitterClientManager { .init(key: apiKey, secret: apiSecret) } - static var __authQueryString: String { - guard let value = plist?.object(forKey: "__TEST_AUTH_QUERY_STRING") as? String else { - fatalError("Couldn't find key '__TEST_AUTH_QUERY_STRING' in 'TwitterAPI-Info.plist'.") + static var callbackProtocol = "twitter-broadcast://" + static var callbackURL: URL { + URL(string: callbackProtocol)! + } + + static var accessKey: String { + guard let value = plist?.object(forKey: "TWITTER_ACCESS_KEY") as? String else { + fatalError("Couldn't find key 'TWITTER_ACCESS_KEY' in 'TwitterAPI-Info.plist'.") } return value } - static var callbackProtocol = "twitter-broadcast://" - static var callbackURL: URL { - URL(string: callbackProtocol)! + static var accessSecret: String { + guard let value = plist?.object(forKey: "TWITTER_ACCESS_SECRET") as? String else { + fatalError("Couldn't find key 'TWITTER_ACCESS_SECRET' in 'TwitterAPI-Info.plist'.") + } + + return value + } + + static var userCredentials: OAuthCredentials { + .init(key: accessKey, secret: accessSecret) } } } diff --git a/Broadcast/TwitterAPI-Info.example.plist b/Broadcast/TwitterAPI-Info.example.plist index 92705a7..2aa68bc 100644 --- a/Broadcast/TwitterAPI-Info.example.plist +++ b/Broadcast/TwitterAPI-Info.example.plist @@ -2,11 +2,13 @@ - __TEST_AUTH_QUERY_STRING - Enter auth query string here (necessary for tests) - API_SECRET + TWITTER_ACCESS_KEY + (Required for tests only) Enter user access token + TWITTER_ACCESS_SECRET + (Required for tests only) Enter user access secret + TWITTER_CONSUMER_KEY Enter API Secret Here - API_KEY + TWITTER_CONSUMER_SECRET Enter API Key Here diff --git a/BroadcastUITests/BroadcastUITests.swift b/BroadcastUITests/BroadcastUITests.swift index e9f6f19..155f049 100644 --- a/BroadcastUITests/BroadcastUITests.swift +++ b/BroadcastUITests/BroadcastUITests.swift @@ -6,6 +6,7 @@ // import XCTest +import Twift extension XCUIApplication { static func initWithLaunchParameters() -> XCUIApplication { @@ -87,16 +88,13 @@ class BroadcastUITests: XCTestCase { let app = XCUIApplication.initWithLaunchParameters() app.launch() - let profilePhoto = app.images["profilePhotoButton"] + let profilePhoto = app.buttons["profilePhotoButton"] profilePhoto.tap() - let logoutHandle = app.descendants(matching: .any).matching(identifier: "logoutProfilePhotoHandle").firstMatch - let logoutTarget = app.images["logoutTarget"] - logoutHandle.press(forDuration: 0.1, thenDragTo: logoutTarget) - - _ = XCTWaiter.wait(for: [expectation(description: "Wait for .5 seconds")], timeout: 0.5) + let logoutHandle = app.descendants(matching: .any).matching(identifier: "logoutButton").firstMatch + logoutHandle.tap() let loginButton = app.buttons["loginButton"] - XCTAssert(loginButton.exists) + XCTAssert(loginButton.waitForExistence(timeout: 1)) } } From e9d0cbe469be8e117f83f4aa13a2cbf171e39712 Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Sun, 30 Jan 2022 16:47:10 +0000 Subject: [PATCH 28/36] Try to generate thumbnails for videos --- Broadcast/BroadcastApp.swift | 2 +- Broadcast/ContentView.swift | 4 +- .../Helper Views/AsyncLocalMediaPreview.swift | 49 ++++++++++++++++++- .../Helper Views/AttachmentThumbnail.swift | 3 +- Broadcast/Helper Views/ComposerView.swift | 2 +- Broadcast/Helper Views/PhotoPicker.swift | 3 +- 6 files changed, 54 insertions(+), 9 deletions(-) diff --git a/Broadcast/BroadcastApp.swift b/Broadcast/BroadcastApp.swift index 7e0f54b..f2bfd92 100644 --- a/Broadcast/BroadcastApp.swift +++ b/Broadcast/BroadcastApp.swift @@ -30,7 +30,7 @@ struct BroadcastApp: App { Color.clear.background(Material.bar) .frame(height: geom.safeAreaInsets.top) .ignoresSafeArea(.container, edges: .top) - } + }.background(Material.bar) } } } diff --git a/Broadcast/ContentView.swift b/Broadcast/ContentView.swift index 60699ef..e068476 100644 --- a/Broadcast/ContentView.swift +++ b/Broadcast/ContentView.swift @@ -92,9 +92,9 @@ struct ContentView: View { .accessibilityIdentifier("loginButton") } } - .buttonStyle(BroadcastButtonStyle()) + .buttonStyle(BroadcastButtonStyle(isLoading: twitterClient.state != .idle)) .padding() - .background(.ultraThinMaterial) + .background(Material.bar) .gesture(DragGesture().onEnded({ _ in UIApplication.shared.endEditing() })) }) } diff --git a/Broadcast/Helper Views/AsyncLocalMediaPreview.swift b/Broadcast/Helper Views/AsyncLocalMediaPreview.swift index 7264195..fa695b7 100644 --- a/Broadcast/Helper Views/AsyncLocalMediaPreview.swift +++ b/Broadcast/Helper Views/AsyncLocalMediaPreview.swift @@ -7,6 +7,7 @@ import SwiftUI import PhotosUI +import QuickLook struct AsyncLocalMediaPreview: View { private enum PreviewLoadingState { @@ -62,7 +63,13 @@ struct AsyncLocalMediaPreview: View { func loadPreview() async { let itemProvider = asset.itemProvider - if itemProvider.canLoadObject(ofClass: UIImage.self) { + + guard let typeIdentifier = itemProvider.registeredTypeIdentifiers.first, + let utType = UTType(typeIdentifier) + else { return self.state = .failed } + + if utType.conforms(to: .image), + itemProvider.canLoadObject(ofClass: UIImage.self) { loadingProgress = itemProvider.loadObject(ofClass: UIImage.self) { image, error in if let image = image as? UIImage { self.state = .loaded(image) @@ -70,8 +77,46 @@ struct AsyncLocalMediaPreview: View { self.state = .failed } } + } else if utType.conforms(to: .video) { + let url: URL? = await withUnsafeContinuation { continuation in + itemProvider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { url, error in + if let error = error { + print(error.localizedDescription) + } + + guard let url = url else { return continuation.resume(returning: nil) } + + let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first + guard let targetURL = documentsDirectory?.appendingPathComponent(url.lastPathComponent) else { + return continuation.resume(returning: nil) + } + + do { + if FileManager.default.fileExists(atPath: targetURL.path) { + try FileManager.default.removeItem(at: targetURL) + } + + try FileManager.default.copyItem(at: url, to: targetURL) + + continuation.resume(returning: targetURL) + } catch { + continuation.resume(returning: nil) + } + } + } + + guard let url = url else { + return state = .failed + } + + let thumbnailRequest = QLThumbnailGenerator.Request.init(fileAt: url, size: .init(width: 800, height: 800), scale: 3.0, representationTypes: .thumbnail) + guard let thumbnail = try? await QLThumbnailGenerator().generateBestRepresentation(for: thumbnailRequest) else { + return state = .failed + } + + state = .loaded(thumbnail.uiImage) } else { - self.state = .failed + state = .failed } } } diff --git a/Broadcast/Helper Views/AttachmentThumbnail.swift b/Broadcast/Helper Views/AttachmentThumbnail.swift index 5eaaed9..8d60ffe 100644 --- a/Broadcast/Helper Views/AttachmentThumbnail.swift +++ b/Broadcast/Helper Views/AttachmentThumbnail.swift @@ -52,10 +52,9 @@ struct AttachmentThumbnail: View { .offset(x: -8, y: 8) } } - } + }.transition(.scale) } } - .transition(.scale) } func itemHasAltText(_ id: String) -> Bool { diff --git a/Broadcast/Helper Views/ComposerView.swift b/Broadcast/Helper Views/ComposerView.swift index 9e89c29..99c238c 100644 --- a/Broadcast/Helper Views/ComposerView.swift +++ b/Broadcast/Helper Views/ComposerView.swift @@ -104,7 +104,7 @@ struct ComposerView: View { .accessibilityIdentifier("tweetComposer") } .font(.broadcastTitle3) - }.transition(.scale) + } Divider() .padding(.bottom, verticalPadding) diff --git a/Broadcast/Helper Views/PhotoPicker.swift b/Broadcast/Helper Views/PhotoPicker.swift index 7297c09..7909a03 100644 --- a/Broadcast/Helper Views/PhotoPicker.swift +++ b/Broadcast/Helper Views/PhotoPicker.swift @@ -19,7 +19,8 @@ extension PHPickerResult { return mediaTypes.reduce(nil, { partialResult, current in if partialResult == nil, let mimeType = current.preferredMIMEType { - return Media.MimeType(rawValue: mimeType) + let castMimeType = mimeType == "image/heic" ? "image/jpeg" : mimeType + return Media.MimeType(rawValue: castMimeType) } else { return nil } From 264e73ce46a474584c3276e60c22cd01fcd2a227 Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Tue, 1 Feb 2022 18:21:10 +0000 Subject: [PATCH 29/36] Try to improve UTType casting and add some debugging code --- .../xcdebugger/Breakpoints_v2.xcbkptlist | 18 +++++++++++ Broadcast/Helper Views/PhotoPicker.swift | 32 +++++++++---------- 2 files changed, 34 insertions(+), 16 deletions(-) diff --git a/Broadcast.xcodeproj/xcuserdata/dte.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/Broadcast.xcodeproj/xcuserdata/dte.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist index 853662d..4c19694 100644 --- a/Broadcast.xcodeproj/xcuserdata/dte.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +++ b/Broadcast.xcodeproj/xcuserdata/dte.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -3,4 +3,22 @@ uuid = "561759D3-8D3F-4CFB-B9B4-4D047A58B1B1" type = "1" version = "2.0"> + + + + + + diff --git a/Broadcast/Helper Views/PhotoPicker.swift b/Broadcast/Helper Views/PhotoPicker.swift index 7909a03..bc54159 100644 --- a/Broadcast/Helper Views/PhotoPicker.swift +++ b/Broadcast/Helper Views/PhotoPicker.swift @@ -11,28 +11,28 @@ import SwiftUI import Twift extension PHPickerResult { - var mediaTypes: [UTType] { - return self.itemProvider.registeredTypeIdentifiers.compactMap { UTType($0) } + var mediaType: UTType? { + if let typeIdentifier = itemProvider.registeredTypeIdentifiers.first { + return UTType(typeIdentifier) + } else if let pathExtension = itemProvider.suggestedName?.split(after: ".").first { + return UTType(filenameExtension: pathExtension) + } else { + print(itemProvider.registeredTypeIdentifiers, itemProvider.suggestedName, itemProvider.preferredPresentationStyle) + return nil + } } var mediaMimeType: Media.MimeType? { - return mediaTypes.reduce(nil, { partialResult, current in - if partialResult == nil, - let mimeType = current.preferredMIMEType { - let castMimeType = mimeType == "image/heic" ? "image/jpeg" : mimeType - return Media.MimeType(rawValue: castMimeType) - } else { - return nil - } - }) + if let mimeType = mediaType?.preferredMIMEType { + let castMimeType = mimeType == "image/heic" ? "image/jpeg" : mimeType + return Media.MimeType(rawValue: castMimeType) + } else { + return nil + } } var allowsAltText: Bool { - guard let mimeType = mediaMimeType else { return false } - switch mimeType { - case .gif, .jpeg, .png: return true - default: return false - } + return mediaType?.conforms(to: .image) ?? false } } From cdebe45671f711a6b69daebb8a21199b2801ef78 Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Sat, 5 Feb 2022 16:36:53 +0000 Subject: [PATCH 30/36] Fixes and improvements --- Broadcast.xcodeproj/project.pbxproj | 4 ++++ Broadcast/BroadcastApp.swift | 4 ++-- Broadcast/ContentView.swift | 2 +- Broadcast/Helper Views/ActionBarView.swift | 1 + Broadcast/Helper Views/PhotoPicker.swift | 2 +- Broadcast/Helper Views/TweetView.swift | 2 +- Broadcast/Helper Views/VisualEffectView.swift | 15 +++++++++++++++ Broadcast/Helpers/TwitterClientManager.swift | 2 +- 8 files changed, 26 insertions(+), 6 deletions(-) create mode 100644 Broadcast/Helper Views/VisualEffectView.swift diff --git a/Broadcast.xcodeproj/project.pbxproj b/Broadcast.xcodeproj/project.pbxproj index 4c7bf34..4ab9009 100644 --- a/Broadcast.xcodeproj/project.pbxproj +++ b/Broadcast.xcodeproj/project.pbxproj @@ -42,6 +42,7 @@ 7199AE7C26B97327001DEB46 /* UserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7199AE7B26B97327001DEB46 /* UserView.swift */; }; 7199AE7E26B973B2001DEB46 /* MentionBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7199AE7D26B973B2001DEB46 /* MentionBar.swift */; }; 7199AE8026B9892E001DEB46 /* Debouncer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7199AE7F26B9892E001DEB46 /* Debouncer.swift */; }; + 719D19C127AEDD4E003120F0 /* VisualEffectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 719D19C027AEDD4E003120F0 /* VisualEffectView.swift */; }; 71A1088A268B073B007E1FFB /* Haptics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71A10889268B073B007E1FFB /* Haptics.swift */; }; 71A6A264278C73AD00BF2387 /* TwitterAPI-Info.example.plist in Resources */ = {isa = PBXBuildFile; fileRef = 71A6A263278C73AD00BF2387 /* TwitterAPI-Info.example.plist */; }; 71A9157927A59C9000706024 /* AsyncLocalMediaPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71A9157827A59C9000706024 /* AsyncLocalMediaPreview.swift */; }; @@ -99,6 +100,7 @@ 7199AE7B26B97327001DEB46 /* UserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserView.swift; sourceTree = ""; }; 7199AE7D26B973B2001DEB46 /* MentionBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionBar.swift; sourceTree = ""; }; 7199AE7F26B9892E001DEB46 /* Debouncer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debouncer.swift; sourceTree = ""; }; + 719D19C027AEDD4E003120F0 /* VisualEffectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectView.swift; sourceTree = ""; }; 71A10889268B073B007E1FFB /* Haptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Haptics.swift; sourceTree = ""; }; 71A6A263278C73AD00BF2387 /* TwitterAPI-Info.example.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "TwitterAPI-Info.example.plist"; sourceTree = ""; }; 71A9157827A59C9000706024 /* AsyncLocalMediaPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncLocalMediaPreview.swift; sourceTree = ""; }; @@ -234,6 +236,7 @@ 717041F426B7037300001360 /* TweetView.swift */, 7199AE7B26B97327001DEB46 /* UserView.swift */, 713E16E927A6EAA900314B44 /* UserAvatar.swift */, + 719D19C027AEDD4E003120F0 /* VisualEffectView.swift */, ); path = "Helper Views"; sourceTree = ""; @@ -409,6 +412,7 @@ 71D283F127A469EF00640B2A /* AttributeScopes+TwitterEntities.swift in Sources */, 719087CF26891C7F005B96CE /* Array.extension.swift in Sources */, 7188E64D2687C6A1007CFD78 /* Binding.extension.swift in Sources */, + 719D19C127AEDD4E003120F0 /* VisualEffectView.swift in Sources */, 7199AE8026B9892E001DEB46 /* Debouncer.swift in Sources */, 711F3FFA268F50C800605C89 /* Animation.extension.swift in Sources */, 71A1088A268B073B007E1FFB /* Haptics.swift in Sources */, diff --git a/Broadcast/BroadcastApp.swift b/Broadcast/BroadcastApp.swift index f2bfd92..8e78025 100644 --- a/Broadcast/BroadcastApp.swift +++ b/Broadcast/BroadcastApp.swift @@ -27,10 +27,10 @@ struct BroadcastApp: App { persistenceController.save() } - Color.clear.background(Material.bar) + VisualEffectView(effect: UIBlurEffect(style: .regular)) .frame(height: geom.safeAreaInsets.top) .ignoresSafeArea(.container, edges: .top) - }.background(Material.bar) + } } } } diff --git a/Broadcast/ContentView.swift b/Broadcast/ContentView.swift index e068476..5db3afc 100644 --- a/Broadcast/ContentView.swift +++ b/Broadcast/ContentView.swift @@ -94,7 +94,7 @@ struct ContentView: View { } .buttonStyle(BroadcastButtonStyle(isLoading: twitterClient.state != .idle)) .padding() - .background(Material.bar) + .background(VisualEffectView(effect: UIBlurEffect(style: .regular)).ignoresSafeArea()) .gesture(DragGesture().onEnded({ _ in UIApplication.shared.endEditing() })) }) } diff --git a/Broadcast/Helper Views/ActionBarView.swift b/Broadcast/Helper Views/ActionBarView.swift index f929b62..02df687 100644 --- a/Broadcast/Helper Views/ActionBarView.swift +++ b/Broadcast/Helper Views/ActionBarView.swift @@ -47,6 +47,7 @@ struct ActionBarView: View { } .onLongPressGesture { ThemeHelper.shared.rotateTheme() + Haptics.shared.sendStandardFeedback(feedbackType: .success) } } diff --git a/Broadcast/Helper Views/PhotoPicker.swift b/Broadcast/Helper Views/PhotoPicker.swift index bc54159..c751db3 100644 --- a/Broadcast/Helper Views/PhotoPicker.swift +++ b/Broadcast/Helper Views/PhotoPicker.swift @@ -32,7 +32,7 @@ extension PHPickerResult { } var allowsAltText: Bool { - return mediaType?.conforms(to: .image) ?? false + return !(mediaType?.conforms(to: .video) ?? false) } } diff --git a/Broadcast/Helper Views/TweetView.swift b/Broadcast/Helper Views/TweetView.swift index 267e82c..f81a24b 100644 --- a/Broadcast/Helper Views/TweetView.swift +++ b/Broadcast/Helper Views/TweetView.swift @@ -30,7 +30,7 @@ struct TweetView: View { VStack(alignment: .leading, spacing: 4) { HStack { if let date = tweet.createdAt { - Text("\(Text(author.name).fontWeight(.bold).foregroundColor(.primary)) \(Text("@\(author.username)")) • \(date.formatted(.relative(presentation: .named)))))") + Text("\(Text(author.name).fontWeight(.bold).foregroundColor(.primary)) \(Text("@\(author.username)")) • \(date.formatted(.relative(presentation: .named)))") .foregroundColor(.secondary) } } diff --git a/Broadcast/Helper Views/VisualEffectView.swift b/Broadcast/Helper Views/VisualEffectView.swift new file mode 100644 index 0000000..3d4179b --- /dev/null +++ b/Broadcast/Helper Views/VisualEffectView.swift @@ -0,0 +1,15 @@ +// +// VisualEffectView.swift +// Broadcast +// +// Created by Daniel Eden on 27/06/2021. +// + +import Foundation +import SwiftUI + +struct VisualEffectView: UIViewRepresentable { + var effect: UIVisualEffect? + func makeUIView(context: UIViewRepresentableContext) -> UIVisualEffectView { UIVisualEffectView() } + func updateUIView(_ uiView: UIVisualEffectView, context: UIViewRepresentableContext) { uiView.effect = effect } +} diff --git a/Broadcast/Helpers/TwitterClientManager.swift b/Broadcast/Helpers/TwitterClientManager.swift index 72be937..379eb0c 100644 --- a/Broadcast/Helpers/TwitterClientManager.swift +++ b/Broadcast/Helpers/TwitterClientManager.swift @@ -316,7 +316,7 @@ fileprivate struct V1User: Codable { extension TwitterClientManager { public func draftIsValid() -> Bool { if let text = draft.text, !text.isEmpty && !text.isBlank { - return TwitterText.remainingCharacterCount(text: text) >= 0 + return TwitterText.tweetLength(text: text) <= 280 } else if !selectedMedia.isEmpty { return true } else { From fed4523bcbbd206f6325045ec7e55a21b8820511 Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Mon, 7 Feb 2022 20:54:04 +0000 Subject: [PATCH 31/36] Fix media tweeting --- .../xcdebugger/Breakpoints_v2.xcbkptlist | 18 ------- .../Helper Views/AsyncLocalMediaPreview.swift | 50 +++++++++++-------- .../Helper Views/AttachmentThumbnail.swift | 22 ++++---- .../Helper Views/BroadcastButtonStyle.swift | 1 + Broadcast/Helper Views/PhotoPicker.swift | 21 ++++---- Broadcast/Helpers/TwitterClientManager.swift | 5 +- 6 files changed, 55 insertions(+), 62 deletions(-) diff --git a/Broadcast.xcodeproj/xcuserdata/dte.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/Broadcast.xcodeproj/xcuserdata/dte.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist index 4c19694..853662d 100644 --- a/Broadcast.xcodeproj/xcuserdata/dte.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +++ b/Broadcast.xcodeproj/xcuserdata/dte.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -3,22 +3,4 @@ uuid = "561759D3-8D3F-4CFB-B9B4-4D047A58B1B1" type = "1" version = "2.0"> - - - - - - diff --git a/Broadcast/Helper Views/AsyncLocalMediaPreview.swift b/Broadcast/Helper Views/AsyncLocalMediaPreview.swift index fa695b7..c912a60 100644 --- a/Broadcast/Helper Views/AsyncLocalMediaPreview.swift +++ b/Broadcast/Helper Views/AsyncLocalMediaPreview.swift @@ -7,18 +7,20 @@ import SwiftUI import PhotosUI +import AVKit import QuickLook struct AsyncLocalMediaPreview: View { private enum PreviewLoadingState { - case loaded(_ image: UIImage) + case loadedImage(_ image: UIImage) + case loadedVideo(_ video: AVPlayer) case failed case loading case loadingWithProgress(_ progress: Progress) var finished: Bool { switch self { - case .loaded(_), .failed: + case .loadedImage(_), .loadedVideo(_), .failed: return true default: return false @@ -30,33 +32,39 @@ struct AsyncLocalMediaPreview: View { @State private var state: PreviewLoadingState = .loading @State var loadingProgress: Progress? + @State private var showLoadingErrorAlert = false var body: some View { Group { switch state { - case .loaded(let image): + case .loadedImage(let image): Image(uiImage: image) .resizable() .scaledToFit() + case .loadedVideo(let player): + VideoPlayer(player: player) + .scaledToFit() + .fixedSize() case .failed: - Label("Cannot load media", systemImage: "eye.slash") + Label("Cannot Load Preview", systemImage: "eye.slash") .foregroundStyle(.secondary) .padding() case .loading: - ProgressView() + ProgressView("Loading Preview") .padding() case .loadingWithProgress(let progress): ProgressView(value: progress.fractionCompleted) .padding() + } } - .frame(maxWidth: .infinity, minHeight: 48) + .frame(maxWidth: .infinity, minHeight: 80) .background(.thinMaterial) .task { await loadPreview() } .onChange(of: loadingProgress) { value in if let value = value, !value.isFinished { - self.state = .loadingWithProgress(value) + withAnimation { self.state = .loadingWithProgress(value) } } } } @@ -64,20 +72,21 @@ struct AsyncLocalMediaPreview: View { func loadPreview() async { let itemProvider = asset.itemProvider - guard let typeIdentifier = itemProvider.registeredTypeIdentifiers.first, + guard let typeIdentifier = itemProvider.registeredTypeIdentifiers.last, let utType = UTType(typeIdentifier) else { return self.state = .failed } - if utType.conforms(to: .image), - itemProvider.canLoadObject(ofClass: UIImage.self) { - loadingProgress = itemProvider.loadObject(ofClass: UIImage.self) { image, error in - if let image = image as? UIImage { - self.state = .loaded(image) - } else { - self.state = .failed + if utType.conforms(to: .image) { + if itemProvider.canLoadObject(ofClass: UIImage.self) { + loadingProgress = itemProvider.loadObject(ofClass: UIImage.self) { image, error in + if let image = image as? UIImage { + self.state = .loadedImage(image) + } else { + self.state = .failed + } } } - } else if utType.conforms(to: .video) { + } else if utType.conforms(to: .movie) { let url: URL? = await withUnsafeContinuation { continuation in itemProvider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { url, error in if let error = error { @@ -108,13 +117,10 @@ struct AsyncLocalMediaPreview: View { guard let url = url else { return state = .failed } - - let thumbnailRequest = QLThumbnailGenerator.Request.init(fileAt: url, size: .init(width: 800, height: 800), scale: 3.0, representationTypes: .thumbnail) - guard let thumbnail = try? await QLThumbnailGenerator().generateBestRepresentation(for: thumbnailRequest) else { - return state = .failed - } - state = .loaded(thumbnail.uiImage) + let player = AVPlayer(url: url) + + state = .loadedVideo(player) } else { state = .failed } diff --git a/Broadcast/Helper Views/AttachmentThumbnail.swift b/Broadcast/Helper Views/AttachmentThumbnail.swift index 8d60ffe..fbb0e50 100644 --- a/Broadcast/Helper Views/AttachmentThumbnail.swift +++ b/Broadcast/Helper Views/AttachmentThumbnail.swift @@ -27,6 +27,16 @@ struct AttachmentThumbnail: View { .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) HStack { + Button(action: { removeImage(key) }) { + Label("Remove Image", systemImage: "xmark") + .labelStyle(.iconOnly) + } + .buttonStyle(BroadcastButtonStyle(paddingSize: 8, prominence: .tertiary, isFullWidth: false)) + .clipShape(Circle()) + .offset(x: 8, y: 8) + + Spacer() + if let item = media[key], item.allowsAltText { Button(action: { selectedMediaId = key }) { @@ -35,21 +45,11 @@ struct AttachmentThumbnail: View { } .buttonStyle(BroadcastButtonStyle(paddingSize: 8, prominence: itemHasAltText(key) ? .primary : .tertiary, isFullWidth: false)) .clipShape(Circle()) - .offset(x: 8, y: 8) + .offset(x: -8, y: 8) .sheet(item: $selectedMediaId) { id in AltTextSheet(assetId: id, asset: item) } } - - Spacer() - - Button(action: { removeImage(key) }) { - Label("Remove Image", systemImage: "xmark") - .labelStyle(.iconOnly) - } - .buttonStyle(BroadcastButtonStyle(paddingSize: 8, prominence: .tertiary, isFullWidth: false)) - .clipShape(Circle()) - .offset(x: -8, y: 8) } } }.transition(.scale) diff --git a/Broadcast/Helper Views/BroadcastButtonStyle.swift b/Broadcast/Helper Views/BroadcastButtonStyle.swift index 191ed73..4fe2787 100644 --- a/Broadcast/Helper Views/BroadcastButtonStyle.swift +++ b/Broadcast/Helper Views/BroadcastButtonStyle.swift @@ -78,6 +78,7 @@ struct BroadcastButtonStyle: ButtonStyle { } .padding(paddingSize) .background(background.padding(-paddingSize)) + .background(.regularMaterial) .foregroundStyle(foregroundColor) .overlay( Group { diff --git a/Broadcast/Helper Views/PhotoPicker.swift b/Broadcast/Helper Views/PhotoPicker.swift index c751db3..6c784a5 100644 --- a/Broadcast/Helper Views/PhotoPicker.swift +++ b/Broadcast/Helper Views/PhotoPicker.swift @@ -12,14 +12,14 @@ import Twift extension PHPickerResult { var mediaType: UTType? { - if let typeIdentifier = itemProvider.registeredTypeIdentifiers.first { - return UTType(typeIdentifier) - } else if let pathExtension = itemProvider.suggestedName?.split(after: ".").first { - return UTType(filenameExtension: pathExtension) - } else { - print(itemProvider.registeredTypeIdentifiers, itemProvider.suggestedName, itemProvider.preferredPresentationStyle) - return nil + for typeIdentifier in itemProvider.registeredTypeIdentifiers { + if let type = UTType(typeIdentifier), + type.preferredMIMEType != nil { + return type + } } + + return nil } var mediaMimeType: Media.MimeType? { @@ -32,7 +32,7 @@ extension PHPickerResult { } var allowsAltText: Bool { - return !(mediaType?.conforms(to: .video) ?? false) + return (mediaType?.conforms(to: .image) ?? false) } } @@ -66,7 +66,10 @@ struct ImagePicker: UIViewControllerRepresentable { func picker(_: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { for result in results { - self.parent.selection[result.assetIdentifier!] = result + // Prevent overriding PHPickerResults for items previously selected + if self.parent.selection[result.assetIdentifier!] == nil { + self.parent.selection[result.assetIdentifier!] = result + } } self.parent.presentationMode.wrappedValue.dismiss() diff --git a/Broadcast/Helpers/TwitterClientManager.swift b/Broadcast/Helpers/TwitterClientManager.swift index 379eb0c..8ba1d32 100644 --- a/Broadcast/Helpers/TwitterClientManager.swift +++ b/Broadcast/Helpers/TwitterClientManager.swift @@ -184,11 +184,12 @@ class TwitterClientManager: ObservableObject { } itemProvider.loadDataRepresentation(forTypeIdentifier: utType.identifier, completionHandler: { data, error in + let castMimeType = utType.preferredMIMEType == "video/quicktime" ? "video/mov" : utType.preferredMIMEType if let data = data, - let mimeType = Media.MimeType(rawValue: utType.preferredMIMEType!) { + let mimeType = Media.MimeType(rawValue: castMimeType!) { continuation.resume(returning: (data, mimeType)) } else { - print("unable to load data for object with type", utType) + return self.updateState(.error("There was a problem Tweeting the attached media. It might be too large, or in an unusual format.")) } }) } From ad3cb2b3fa4736fd8c55e56142a01cb9e72e16a1 Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Mon, 7 Feb 2022 21:11:08 +0000 Subject: [PATCH 32/36] try to fix video tweeting --- .../xcdebugger/Breakpoints_v2.xcbkptlist | 50 +++++++++++++++++++ Broadcast/Helper Views/ActionBarView.swift | 1 + Broadcast/Helper Views/PhotoPicker.swift | 4 ++ Broadcast/Helpers/TwitterClientManager.swift | 14 ++---- 4 files changed, 59 insertions(+), 10 deletions(-) diff --git a/Broadcast.xcodeproj/xcuserdata/dte.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/Broadcast.xcodeproj/xcuserdata/dte.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist index 853662d..3fe4ffa 100644 --- a/Broadcast.xcodeproj/xcuserdata/dte.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +++ b/Broadcast.xcodeproj/xcuserdata/dte.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -3,4 +3,54 @@ uuid = "561759D3-8D3F-4CFB-B9B4-4D047A58B1B1" type = "1" version = "2.0"> + + + + + + + + + + + + + + + + + + + + + + diff --git a/Broadcast/Helper Views/ActionBarView.swift b/Broadcast/Helper Views/ActionBarView.swift index 02df687..7004868 100644 --- a/Broadcast/Helper Views/ActionBarView.swift +++ b/Broadcast/Helper Views/ActionBarView.swift @@ -39,6 +39,7 @@ struct ActionBarView: View { } var body: some View { + ProgressView(twitterClient.uploadProgress) publishingActions .disabled(twitterClient.state == .busy()) .sheet(isPresented: $photoPickerIsPresented) { diff --git a/Broadcast/Helper Views/PhotoPicker.swift b/Broadcast/Helper Views/PhotoPicker.swift index 6c784a5..235b116 100644 --- a/Broadcast/Helper Views/PhotoPicker.swift +++ b/Broadcast/Helper Views/PhotoPicker.swift @@ -13,6 +13,10 @@ import Twift extension PHPickerResult { var mediaType: UTType? { for typeIdentifier in itemProvider.registeredTypeIdentifiers { + if typeIdentifier == "video/quicktime" { + continue + } + if let type = UTType(typeIdentifier), type.preferredMIMEType != nil { return type diff --git a/Broadcast/Helpers/TwitterClientManager.swift b/Broadcast/Helpers/TwitterClientManager.swift index 8ba1d32..7902385 100644 --- a/Broadcast/Helpers/TwitterClientManager.swift +++ b/Broadcast/Helpers/TwitterClientManager.swift @@ -51,6 +51,8 @@ class TwitterClientManager: ObservableObject { @Published var selectedMedia: [String: PHPickerResult] = [:] @Published var mediaAltText: [String: String] = [:] + @Published var uploadProgress = Progress() + @MainActor init() { if let storedCredentials = self.retreiveCredentials() { @@ -173,13 +175,7 @@ class TwitterClientManager: ObservableObject { for (key, media) in selectedMedia { let media: (Data, Media.MimeType)? = await withUnsafeContinuation { continuation in let itemProvider = media.itemProvider - guard let utType = itemProvider.registeredTypeIdentifiers.reduce(nil, { (guess: UTType?, current: String) in - if guess == nil { - return UTType(current) - } else { - return nil - } - }) else { + guard let utType = media.mediaType else { return continuation.resume(returning: nil) } @@ -198,7 +194,7 @@ class TwitterClientManager: ObservableObject { return updateState(.genericTextAndMediaError) } - let result = try await client.upload(mediaData: data, mimeType: mimeType) + let result = try await client.upload(mediaData: data, mimeType: mimeType, progress: &self.uploadProgress) if let altText = mediaAltText[key] { try await client.addAltText(to: result.mediaIdString, text: altText) @@ -222,8 +218,6 @@ class TwitterClientManager: ObservableObject { print(error) sendTweetCallback(response: nil, error: error) } - - updateState(.idle) } /// Asynchronously update client state on the main thread From 94c1b0088ca32328e12f4d8e9d3601bd4018a2b4 Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Tue, 8 Feb 2022 13:28:57 +0000 Subject: [PATCH 33/36] Oh my god video finally works --- .../xcshareddata/swiftpm/Package.resolved | 2 +- Broadcast/Helper Views/ActionBarView.swift | 5 ++-- Broadcast/Helper Views/PhotoPicker.swift | 13 ----------- Broadcast/Helpers/TwitterClientManager.swift | 23 +++++++++++++------ 4 files changed, 19 insertions(+), 24 deletions(-) diff --git a/Broadcast.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Broadcast.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b8b3bf0..db0b798 100644 --- a/Broadcast.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Broadcast.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -24,7 +24,7 @@ "repositoryURL": "https://github.com/daneden/Twift.git", "state": { "branch": "main", - "revision": "b4357030d7b266faaacf40bf886836dd06cadf88", + "revision": "8b17110f0c85ac4db7d6c2d6912eb3ef443c73f6", "version": null } }, diff --git a/Broadcast/Helper Views/ActionBarView.swift b/Broadcast/Helper Views/ActionBarView.swift index 7004868..dcbb572 100644 --- a/Broadcast/Helper Views/ActionBarView.swift +++ b/Broadcast/Helper Views/ActionBarView.swift @@ -18,7 +18,7 @@ struct ActionBarView: View { private var pickerConfig: PHPickerConfiguration { var config = PHPickerConfiguration(photoLibrary: .shared()) - config.preferredAssetRepresentationMode = .current + config.preferredAssetRepresentationMode = .compatible config.selection = .ordered if moreMediaAllowed && !twitterClient.selectedMedia.isEmpty { @@ -33,13 +33,12 @@ struct ActionBarView: View { } private var moreMediaAllowed: Bool { - if twitterClient.selectedMedia.contains(where: { $0.value.mediaMimeType == .mov || $0.value.mediaMimeType == .gif }) { return false } + if twitterClient.selectedMedia.contains(where: { $0.value.mediaType!.conforms(to: .movie) || $0.value.mediaType!.conforms(to: .video) }) { return false } if twitterClient.selectedMedia.count == 4 { return false } return true } var body: some View { - ProgressView(twitterClient.uploadProgress) publishingActions .disabled(twitterClient.state == .busy()) .sheet(isPresented: $photoPickerIsPresented) { diff --git a/Broadcast/Helper Views/PhotoPicker.swift b/Broadcast/Helper Views/PhotoPicker.swift index 235b116..85439b3 100644 --- a/Broadcast/Helper Views/PhotoPicker.swift +++ b/Broadcast/Helper Views/PhotoPicker.swift @@ -13,10 +13,6 @@ import Twift extension PHPickerResult { var mediaType: UTType? { for typeIdentifier in itemProvider.registeredTypeIdentifiers { - if typeIdentifier == "video/quicktime" { - continue - } - if let type = UTType(typeIdentifier), type.preferredMIMEType != nil { return type @@ -26,15 +22,6 @@ extension PHPickerResult { return nil } - var mediaMimeType: Media.MimeType? { - if let mimeType = mediaType?.preferredMIMEType { - let castMimeType = mimeType == "image/heic" ? "image/jpeg" : mimeType - return Media.MimeType(rawValue: castMimeType) - } else { - return nil - } - } - var allowsAltText: Bool { return (mediaType?.conforms(to: .image) ?? false) } diff --git a/Broadcast/Helpers/TwitterClientManager.swift b/Broadcast/Helpers/TwitterClientManager.swift index 7902385..f97cc6f 100644 --- a/Broadcast/Helpers/TwitterClientManager.swift +++ b/Broadcast/Helpers/TwitterClientManager.swift @@ -173,19 +173,28 @@ class TwitterClientManager: ObservableObject { var mediaStrings: [String] = [] for (key, media) in selectedMedia { - let media: (Data, Media.MimeType)? = await withUnsafeContinuation { continuation in + let media: (Data, String)? = await withUnsafeContinuation { continuation in + var utType: UTType + let itemProvider = media.itemProvider - guard let utType = media.mediaType else { + + if itemProvider.hasItemConformingToTypeIdentifier(UTType.image.identifier) { + utType = media.mediaType ?? .image + } else if itemProvider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) { + utType = media.mediaType ?? .movie + } else { + return continuation.resume(returning: nil) + } + + guard let mimeTypeString = utType.preferredMIMEType else { return continuation.resume(returning: nil) } itemProvider.loadDataRepresentation(forTypeIdentifier: utType.identifier, completionHandler: { data, error in - let castMimeType = utType.preferredMIMEType == "video/quicktime" ? "video/mov" : utType.preferredMIMEType - if let data = data, - let mimeType = Media.MimeType(rawValue: castMimeType!) { - continuation.resume(returning: (data, mimeType)) + if let data = data { + continuation.resume(returning: (data, mimeTypeString)) } else { - return self.updateState(.error("There was a problem Tweeting the attached media. It might be too large, or in an unusual format.")) + return self.updateState(.error("There was a problem Tweeting the attached media because it's in an unusual format.")) } }) } From da066397989dc37de5e4fccdc3fcc1553b3700a7 Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Tue, 8 Feb 2022 17:52:34 +0000 Subject: [PATCH 34/36] Start working on drag-and-drop --- .../xcdebugger/Breakpoints_v2.xcbkptlist | 50 ----------- Broadcast/ContentView.swift | 1 + Broadcast/Helper Views/ActionBarView.swift | 2 +- .../Helper Views/AsyncLocalMediaPreview.swift | 4 +- .../Helper Views/AttachmentThumbnail.swift | 4 +- Broadcast/Helper Views/PhotoPicker.swift | 8 +- Broadcast/Helpers/TwitterClientManager.swift | 88 +++++++++++++++---- 7 files changed, 83 insertions(+), 74 deletions(-) diff --git a/Broadcast.xcodeproj/xcuserdata/dte.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/Broadcast.xcodeproj/xcuserdata/dte.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist index 3fe4ffa..853662d 100644 --- a/Broadcast.xcodeproj/xcuserdata/dte.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +++ b/Broadcast.xcodeproj/xcuserdata/dte.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -3,54 +3,4 @@ uuid = "561759D3-8D3F-4CFB-B9B4-4D047A58B1B1" type = "1" version = "2.0"> - - - - - - - - - - - - - - - - - - - - - - diff --git a/Broadcast/ContentView.swift b/Broadcast/ContentView.swift index 5db3afc..95b7918 100644 --- a/Broadcast/ContentView.swift +++ b/Broadcast/ContentView.swift @@ -118,6 +118,7 @@ struct ContentView: View { }.background(.background) } } + .onDrop(of: [.image], delegate: twitterClient) } } } diff --git a/Broadcast/Helper Views/ActionBarView.swift b/Broadcast/Helper Views/ActionBarView.swift index dcbb572..f6328cc 100644 --- a/Broadcast/Helper Views/ActionBarView.swift +++ b/Broadcast/Helper Views/ActionBarView.swift @@ -18,7 +18,7 @@ struct ActionBarView: View { private var pickerConfig: PHPickerConfiguration { var config = PHPickerConfiguration(photoLibrary: .shared()) - config.preferredAssetRepresentationMode = .compatible + config.preferredAssetRepresentationMode = .automatic config.selection = .ordered if moreMediaAllowed && !twitterClient.selectedMedia.isEmpty { diff --git a/Broadcast/Helper Views/AsyncLocalMediaPreview.swift b/Broadcast/Helper Views/AsyncLocalMediaPreview.swift index c912a60..0644964 100644 --- a/Broadcast/Helper Views/AsyncLocalMediaPreview.swift +++ b/Broadcast/Helper Views/AsyncLocalMediaPreview.swift @@ -28,7 +28,7 @@ struct AsyncLocalMediaPreview: View { } } var assetId: String - var asset: PHPickerResult + var asset: NSItemProvider @State private var state: PreviewLoadingState = .loading @State var loadingProgress: Progress? @@ -70,7 +70,7 @@ struct AsyncLocalMediaPreview: View { } func loadPreview() async { - let itemProvider = asset.itemProvider + let itemProvider = asset guard let typeIdentifier = itemProvider.registeredTypeIdentifiers.last, let utType = UTType(typeIdentifier) diff --git a/Broadcast/Helper Views/AttachmentThumbnail.swift b/Broadcast/Helper Views/AttachmentThumbnail.swift index fbb0e50..48b0816 100644 --- a/Broadcast/Helper Views/AttachmentThumbnail.swift +++ b/Broadcast/Helper Views/AttachmentThumbnail.swift @@ -14,7 +14,7 @@ extension String: Identifiable { struct AttachmentThumbnail: View { @EnvironmentObject var twitterClient: TwitterClientManager - @Binding var media: [String: PHPickerResult] + @Binding var media: [String: NSItemProvider] @State private var altTextSheetIsPresented = false @State private var selectedMediaId: String? @@ -73,7 +73,7 @@ fileprivate struct AltTextSheet: View { @Environment(\.presentationMode) var presentationMode var assetId: String - var asset: PHPickerResult + var asset: NSItemProvider @State var altText = "" diff --git a/Broadcast/Helper Views/PhotoPicker.swift b/Broadcast/Helper Views/PhotoPicker.swift index 85439b3..93c5f08 100644 --- a/Broadcast/Helper Views/PhotoPicker.swift +++ b/Broadcast/Helper Views/PhotoPicker.swift @@ -10,9 +10,9 @@ import PhotosUI import SwiftUI import Twift -extension PHPickerResult { +extension NSItemProvider { var mediaType: UTType? { - for typeIdentifier in itemProvider.registeredTypeIdentifiers { + for typeIdentifier in registeredTypeIdentifiers { if let type = UTType(typeIdentifier), type.preferredMIMEType != nil { return type @@ -31,7 +31,7 @@ struct ImagePicker: UIViewControllerRepresentable { @Environment(\.presentationMode) var presentationMode var configuration: PHPickerConfiguration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared()) - @Binding var selection: [String: PHPickerResult] + @Binding var selection: [String: NSItemProvider] func makeUIViewController(context: UIViewControllerRepresentableContext) -> PHPickerViewController { let controller = PHPickerViewController(configuration: configuration) @@ -59,7 +59,7 @@ struct ImagePicker: UIViewControllerRepresentable { for result in results { // Prevent overriding PHPickerResults for items previously selected if self.parent.selection[result.assetIdentifier!] == nil { - self.parent.selection[result.assetIdentifier!] = result + self.parent.selection[result.assetIdentifier!] = result.itemProvider } } diff --git a/Broadcast/Helpers/TwitterClientManager.swift b/Broadcast/Helpers/TwitterClientManager.swift index f97cc6f..0502a1d 100644 --- a/Broadcast/Helpers/TwitterClientManager.swift +++ b/Broadcast/Helpers/TwitterClientManager.swift @@ -24,8 +24,8 @@ extension AuthenticatedIds: RawRepresentable { public init?(rawValue: RawValue) { guard let data = rawValue.data(using: .utf8), let result = try? JSONDecoder().decode(AuthenticatedIds.self, from: data) else { - return nil - } + return nil + } self = result } @@ -33,8 +33,8 @@ extension AuthenticatedIds: RawRepresentable { public var rawValue: RawValue { guard let encoded = try? JSONEncoder().encode(self), let result = String(data: encoded, encoding: .utf8) else { - return "[]" - } + return "[]" + } return result } @@ -48,7 +48,7 @@ class TwitterClientManager: ObservableObject { @Published var lastTweet: Tweet? @Published var client: Twift? - @Published var selectedMedia: [String: PHPickerResult] = [:] + @Published var selectedMedia: [String: NSItemProvider] = [:] @Published var mediaAltText: [String: String] = [:] @Published var uploadProgress = Progress() @@ -163,9 +163,9 @@ class TwitterClientManager: ObservableObject { guard let client = self.client else { return } - + updateState(.busy()) - + do { if asReply, let lastTweet = lastTweet { draft.reply = .init(inReplyToTweetId: lastTweet.id) @@ -173,14 +173,13 @@ class TwitterClientManager: ObservableObject { var mediaStrings: [String] = [] for (key, media) in selectedMedia { + let media: (Data, String)? = await withUnsafeContinuation { continuation in var utType: UTType - let itemProvider = media.itemProvider - - if itemProvider.hasItemConformingToTypeIdentifier(UTType.image.identifier) { + if media.hasItemConformingToTypeIdentifier(UTType.image.identifier) { utType = media.mediaType ?? .image - } else if itemProvider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) { + } else if media.hasItemConformingToTypeIdentifier(UTType.movie.identifier) { utType = media.mediaType ?? .movie } else { return continuation.resume(returning: nil) @@ -190,7 +189,12 @@ class TwitterClientManager: ObservableObject { return continuation.resume(returning: nil) } - itemProvider.loadDataRepresentation(forTypeIdentifier: utType.identifier, completionHandler: { data, error in + media.loadDataRepresentation(forTypeIdentifier: utType.identifier, completionHandler: { data, error in + if let error = error { + print(error) + return self.sendTweetCallback(response: nil, error: error) + } + if let data = data { continuation.resume(returning: (data, mimeTypeString)) } else { @@ -204,15 +208,15 @@ class TwitterClientManager: ObservableObject { } let result = try await client.upload(mediaData: data, mimeType: mimeType, progress: &self.uploadProgress) - + if let altText = mediaAltText[key] { try await client.addAltText(to: result.mediaIdString, text: altText) } - + if result.processingInfo != nil { _ = try await client.checkMediaUploadSuccessful(result.mediaIdString) } - + mediaStrings.append(result.mediaIdString) } @@ -439,3 +443,57 @@ fileprivate struct TypeaheadResponse: Decodable { var num_results: Int var users: [V1User]? } + +extension TwitterClientManager: DropDelegate { + func performDrop(info: DropInfo) -> Bool { + let videoProviders = info.itemProviders(for: [.movie]) + let imageProviders = info.itemProviders(for: [.image]) + + guard videoProviders.count <= 1 else { return false } + guard imageProviders.count <= 4 else { return false } + + guard (!videoProviders.isEmpty && imageProviders.isEmpty) || + (videoProviders.isEmpty && !imageProviders.isEmpty) else { + return false + } + + for provider in info.itemProviders(for: [.image]) { + guard let mediaType = provider.mediaType else { return false } + + if self.selectedMedia.count < 4 { + let id = UUID().uuidString + provider.loadItem(forTypeIdentifier: mediaType.identifier, options: nil) { result, error in + if let error = error { + print(error) + } else if let result = result { + withAnimation { + DispatchQueue.main.async { + self.selectedMedia[id] = NSItemProvider(item: result, typeIdentifier: mediaType.identifier) + } + } + } + } + } + } + + for provider in videoProviders { + guard let mediaType = provider.mediaType else { return false } + if self.selectedMedia.count < 1 { + let id = UUID().uuidString + provider.loadItem(forTypeIdentifier: mediaType.identifier, options: nil) { result, error in + if let error = error { + print(error) + } else if let result = result { + withAnimation { + DispatchQueue.main.async { + self.selectedMedia[id] = NSItemProvider(item: result, typeIdentifier: mediaType.identifier) + } + } + } + } + } + } + + return true + } +} From 3e9f1bb348a4995dc68116f0865ab2bb90ddf11e Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Wed, 9 Feb 2022 14:37:03 +0000 Subject: [PATCH 35/36] UX improvements for drag-and-dropping media --- Broadcast.xcodeproj/project.pbxproj | 4 + Broadcast/ContentView.swift | 1 - Broadcast/Helper Views/ComposerView.swift | 20 +++++ .../Helpers/AttachmentDropDelegate.swift | 83 +++++++++++++++++++ Broadcast/Helpers/TwitterClientManager.swift | 54 ------------ 5 files changed, 107 insertions(+), 55 deletions(-) create mode 100644 Broadcast/Helpers/AttachmentDropDelegate.swift diff --git a/Broadcast.xcodeproj/project.pbxproj b/Broadcast.xcodeproj/project.pbxproj index 4ab9009..c862e87 100644 --- a/Broadcast.xcodeproj/project.pbxproj +++ b/Broadcast.xcodeproj/project.pbxproj @@ -38,6 +38,7 @@ 7188E66C2688DB44007CFD78 /* UIApplication.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E66B2688DB44007CFD78 /* UIApplication.extension.swift */; }; 719087CD26891586005B96CE /* TwitterText in Frameworks */ = {isa = PBXBuildFile; productRef = 719087CC26891586005B96CE /* TwitterText */; }; 719087CF26891C7F005B96CE /* Array.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 719087CE26891C7F005B96CE /* Array.extension.swift */; }; + 7197795B27B408540079AD69 /* AttachmentDropDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7197795A27B408540079AD69 /* AttachmentDropDelegate.swift */; }; 7199AE7A26B96D0D001DEB46 /* NSRegularExpression+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7199AE7926B96D0D001DEB46 /* NSRegularExpression+Convenience.swift */; }; 7199AE7C26B97327001DEB46 /* UserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7199AE7B26B97327001DEB46 /* UserView.swift */; }; 7199AE7E26B973B2001DEB46 /* MentionBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7199AE7D26B973B2001DEB46 /* MentionBar.swift */; }; @@ -96,6 +97,7 @@ 7188E6642688A436007CFD78 /* ThemeHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeHelper.swift; sourceTree = ""; }; 7188E66B2688DB44007CFD78 /* UIApplication.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplication.extension.swift; sourceTree = ""; }; 719087CE26891C7F005B96CE /* Array.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.extension.swift; sourceTree = ""; }; + 7197795A27B408540079AD69 /* AttachmentDropDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentDropDelegate.swift; sourceTree = ""; }; 7199AE7926B96D0D001DEB46 /* NSRegularExpression+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSRegularExpression+Convenience.swift"; sourceTree = ""; }; 7199AE7B26B97327001DEB46 /* UserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserView.swift; sourceTree = ""; }; 7199AE7D26B973B2001DEB46 /* MentionBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionBar.swift; sourceTree = ""; }; @@ -214,6 +216,7 @@ 715AAE0B26C9589A002BCEA1 /* TestUtils.swift */, 7188E6642688A436007CFD78 /* ThemeHelper.swift */, 71B8290B268D0AC6002AEE72 /* TwitterClientManager.swift */, + 7197795A27B408540079AD69 /* AttachmentDropDelegate.swift */, ); path = Helpers; sourceTree = ""; @@ -396,6 +399,7 @@ 71800BF026905DB3009D11A1 /* EngagementCountersView.swift in Sources */, 7199AE7C26B97327001DEB46 /* UserView.swift in Sources */, 7188E6652688A436007CFD78 /* ThemeHelper.swift in Sources */, + 7197795B27B408540079AD69 /* AttachmentDropDelegate.swift in Sources */, 7199AE7E26B973B2001DEB46 /* MentionBar.swift in Sources */, 71800BF226906721009D11A1 /* ActionBarView.swift in Sources */, 7188E64F2687CCD0007CFD78 /* PhotoPicker.swift in Sources */, diff --git a/Broadcast/ContentView.swift b/Broadcast/ContentView.swift index 95b7918..5db3afc 100644 --- a/Broadcast/ContentView.swift +++ b/Broadcast/ContentView.swift @@ -118,7 +118,6 @@ struct ContentView: View { }.background(.background) } } - .onDrop(of: [.image], delegate: twitterClient) } } } diff --git a/Broadcast/Helper Views/ComposerView.swift b/Broadcast/Helper Views/ComposerView.swift index 99c238c..09490f7 100644 --- a/Broadcast/Helper Views/ComposerView.swift +++ b/Broadcast/Helper Views/ComposerView.swift @@ -30,6 +30,7 @@ struct ComposerView: View { @State private var placeholder: String = placeholderCandidates.randomElement() @State private var draftListVisible = false + @State private var dropActive = false private let mentioningRegex = NSRegularExpression("@[a-z0-9_]+$", options: .caseInsensitive) @@ -102,6 +103,7 @@ struct ComposerView: View { .keyboardType(.twitter) .padding(.top, (verticalPadding / 3) * -1) .accessibilityIdentifier("tweetComposer") + .disabled(dropActive) } .font(.broadcastTitle3) } @@ -158,6 +160,24 @@ struct ComposerView: View { } } } + .overlay { + if dropActive { + ZStack { + Color.clear + + VStack { + Image(systemName: "photo.on.rectangle.angled") + Text("Add media attachment") + } + .foregroundStyle(.primary) + .padding() + } + .background(.ultraThinMaterial) + .background(.tertiary) + .foregroundStyle(.tint) + } + } + .onDrop(of: [.image], delegate: AttachmentDropDelegate(dropActive: $dropActive, twitterClient: twitterClient)) if let users = mentionCandidates, !users.isEmpty, diff --git a/Broadcast/Helpers/AttachmentDropDelegate.swift b/Broadcast/Helpers/AttachmentDropDelegate.swift new file mode 100644 index 0000000..db8413d --- /dev/null +++ b/Broadcast/Helpers/AttachmentDropDelegate.swift @@ -0,0 +1,83 @@ +// +// AttachmentDropDelegate.swift +// Broadcast +// +// Created by Daniel Eden on 09/02/2022. +// + +import Foundation +import SwiftUI + +struct AttachmentDropDelegate: DropDelegate { + @Binding var dropActive: Bool + @ObservedObject var twitterClient: TwitterClientManager + + func dropEntered(info: DropInfo) { + withAnimation(.springAnimation) { + if dropActive == false { + dropActive = true + } + } + } + + func dropExited(info: DropInfo) { + withAnimation(.springAnimation) { + if dropActive == true { + dropActive = false + } + } + } + + func performDrop(info: DropInfo) -> Bool { + withAnimation(.springAnimation) { dropActive = false } + let videoProviders = info.itemProviders(for: [.movie]) + let imageProviders = info.itemProviders(for: [.image]) + + guard videoProviders.count <= 1 else { return false } + guard imageProviders.count <= 4 else { return false } + + guard (!videoProviders.isEmpty && imageProviders.isEmpty) || + (videoProviders.isEmpty && !imageProviders.isEmpty) else { + return false + } + + for provider in info.itemProviders(for: [.image]) { + guard let mediaType = provider.mediaType else { return false } + + if twitterClient.selectedMedia.count < 4 { + let id = UUID().uuidString + provider.loadItem(forTypeIdentifier: mediaType.identifier, options: nil) { result, error in + if let error = error { + print(error) + } else if let result = result { + withAnimation { + DispatchQueue.main.async { + twitterClient.selectedMedia[id] = NSItemProvider(item: result, typeIdentifier: mediaType.identifier) + } + } + } + } + } + } + + for provider in videoProviders { + guard let mediaType = provider.mediaType else { return false } + if twitterClient.selectedMedia.count < 1 { + let id = UUID().uuidString + provider.loadItem(forTypeIdentifier: mediaType.identifier, options: nil) { result, error in + if let error = error { + print(error) + } else if let result = result { + withAnimation { + DispatchQueue.main.async { + twitterClient.selectedMedia[id] = NSItemProvider(item: result, typeIdentifier: mediaType.identifier) + } + } + } + } + } + } + + return true + } +} diff --git a/Broadcast/Helpers/TwitterClientManager.swift b/Broadcast/Helpers/TwitterClientManager.swift index 0502a1d..1129a73 100644 --- a/Broadcast/Helpers/TwitterClientManager.swift +++ b/Broadcast/Helpers/TwitterClientManager.swift @@ -443,57 +443,3 @@ fileprivate struct TypeaheadResponse: Decodable { var num_results: Int var users: [V1User]? } - -extension TwitterClientManager: DropDelegate { - func performDrop(info: DropInfo) -> Bool { - let videoProviders = info.itemProviders(for: [.movie]) - let imageProviders = info.itemProviders(for: [.image]) - - guard videoProviders.count <= 1 else { return false } - guard imageProviders.count <= 4 else { return false } - - guard (!videoProviders.isEmpty && imageProviders.isEmpty) || - (videoProviders.isEmpty && !imageProviders.isEmpty) else { - return false - } - - for provider in info.itemProviders(for: [.image]) { - guard let mediaType = provider.mediaType else { return false } - - if self.selectedMedia.count < 4 { - let id = UUID().uuidString - provider.loadItem(forTypeIdentifier: mediaType.identifier, options: nil) { result, error in - if let error = error { - print(error) - } else if let result = result { - withAnimation { - DispatchQueue.main.async { - self.selectedMedia[id] = NSItemProvider(item: result, typeIdentifier: mediaType.identifier) - } - } - } - } - } - } - - for provider in videoProviders { - guard let mediaType = provider.mediaType else { return false } - if self.selectedMedia.count < 1 { - let id = UUID().uuidString - provider.loadItem(forTypeIdentifier: mediaType.identifier, options: nil) { result, error in - if let error = error { - print(error) - } else if let result = result { - withAnimation { - DispatchQueue.main.async { - self.selectedMedia[id] = NSItemProvider(item: result, typeIdentifier: mediaType.identifier) - } - } - } - } - } - } - - return true - } -} From c736d6ed377a4af63c6b61a696ff452d7310d7ed Mon Sep 17 00:00:00 2001 From: Daniel Eden Date: Sun, 13 Feb 2022 17:36:42 +0000 Subject: [PATCH 36/36] Fix video uploads and compression --- Broadcast.xcodeproj/project.pbxproj | 16 +++- .../xcshareddata/swiftpm/Package.resolved | 2 +- Broadcast/ContentView.swift | 5 +- .../EnvironmentKeys+CornerRadius.swift | 20 +++++ .../TwitterClientManager+CompressMedia.swift | 31 +++++++ Broadcast/Helper Views/ActionBarView.swift | 17 +++- .../Helper Views/AttachmentThumbnail.swift | 12 +-- .../Helper Views/BroadcastButtonStyle.swift | 10 ++- Broadcast/Helper Views/ComposerView.swift | 13 ++- Broadcast/Helper Views/DraftsListView.swift | 11 --- .../Helper Views/LastTweetReplyView.swift | 3 +- ...aPreview.swift => LocalMediaPreview.swift} | 3 +- Broadcast/Helper Views/MentionBar.swift | 3 +- Broadcast/Helpers/TwitterClientManager.swift | 81 +++++++++++++++---- 14 files changed, 176 insertions(+), 51 deletions(-) create mode 100644 Broadcast/Extensions/EnvironmentKeys+CornerRadius.swift create mode 100644 Broadcast/Extensions/TwitterClientManager+CompressMedia.swift rename Broadcast/Helper Views/{AsyncLocalMediaPreview.swift => LocalMediaPreview.swift} (98%) diff --git a/Broadcast.xcodeproj/project.pbxproj b/Broadcast.xcodeproj/project.pbxproj index c862e87..de308f6 100644 --- a/Broadcast.xcodeproj/project.pbxproj +++ b/Broadcast.xcodeproj/project.pbxproj @@ -8,6 +8,8 @@ /* Begin PBXBuildFile section */ 7101073626C810AC00A713A5 /* NullStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7101073526C810AC00A713A5 /* NullStateView.swift */; }; + 710F03BF27B6700D00AE6C5B /* TwitterClientManager+CompressMedia.swift in Sources */ = {isa = PBXBuildFile; fileRef = 710F03BE27B6700D00AE6C5B /* TwitterClientManager+CompressMedia.swift */; }; + 710F03C127B970FB00AE6C5B /* EnvironmentKeys+CornerRadius.swift in Sources */ = {isa = PBXBuildFile; fileRef = 710F03C027B970FB00AE6C5B /* EnvironmentKeys+CornerRadius.swift */; }; 711EF99426C959A700FD8A9F /* BroadcastUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711EF99326C959A700FD8A9F /* BroadcastUITests.swift */; }; 711F3FFA268F50C800605C89 /* Animation.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711F3FF9268F50C800605C89 /* Animation.extension.swift */; }; 713E16EA27A6EAA900314B44 /* UserAvatar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 713E16E927A6EAA900314B44 /* UserAvatar.swift */; }; @@ -46,7 +48,7 @@ 719D19C127AEDD4E003120F0 /* VisualEffectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 719D19C027AEDD4E003120F0 /* VisualEffectView.swift */; }; 71A1088A268B073B007E1FFB /* Haptics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71A10889268B073B007E1FFB /* Haptics.swift */; }; 71A6A264278C73AD00BF2387 /* TwitterAPI-Info.example.plist in Resources */ = {isa = PBXBuildFile; fileRef = 71A6A263278C73AD00BF2387 /* TwitterAPI-Info.example.plist */; }; - 71A9157927A59C9000706024 /* AsyncLocalMediaPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71A9157827A59C9000706024 /* AsyncLocalMediaPreview.swift */; }; + 71A9157927A59C9000706024 /* LocalMediaPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71A9157827A59C9000706024 /* LocalMediaPreview.swift */; }; 71B8290C268D0AC6002AEE72 /* TwitterClientManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71B8290B268D0AC6002AEE72 /* TwitterClientManager.swift */; }; 71B82910268F2195002AEE72 /* LastTweetReplyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71B8290F268F2195002AEE72 /* LastTweetReplyView.swift */; }; 71BBAAE9268CF1BD004048A0 /* ComposerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71BBAAE8268CF1BD004048A0 /* ComposerView.swift */; }; @@ -67,6 +69,8 @@ /* Begin PBXFileReference section */ 7101073526C810AC00A713A5 /* NullStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NullStateView.swift; sourceTree = ""; }; + 710F03BE27B6700D00AE6C5B /* TwitterClientManager+CompressMedia.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TwitterClientManager+CompressMedia.swift"; sourceTree = ""; }; + 710F03C027B970FB00AE6C5B /* EnvironmentKeys+CornerRadius.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EnvironmentKeys+CornerRadius.swift"; sourceTree = ""; }; 711EF99126C959A700FD8A9F /* BroadcastUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BroadcastUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 711EF99326C959A700FD8A9F /* BroadcastUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastUITests.swift; sourceTree = ""; }; 711EF99526C959A700FD8A9F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -105,7 +109,7 @@ 719D19C027AEDD4E003120F0 /* VisualEffectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectView.swift; sourceTree = ""; }; 71A10889268B073B007E1FFB /* Haptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Haptics.swift; sourceTree = ""; }; 71A6A263278C73AD00BF2387 /* TwitterAPI-Info.example.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "TwitterAPI-Info.example.plist"; sourceTree = ""; }; - 71A9157827A59C9000706024 /* AsyncLocalMediaPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncLocalMediaPreview.swift; sourceTree = ""; }; + 71A9157827A59C9000706024 /* LocalMediaPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalMediaPreview.swift; sourceTree = ""; }; 71B8290B268D0AC6002AEE72 /* TwitterClientManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwitterClientManager.swift; sourceTree = ""; }; 71B8290F268F2195002AEE72 /* LastTweetReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastTweetReplyView.swift; sourceTree = ""; }; 71BBAAE8268CF1BD004048A0 /* ComposerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerView.swift; sourceTree = ""; }; @@ -200,6 +204,8 @@ 7188E66B2688DB44007CFD78 /* UIApplication.extension.swift */, 717041F626B703A600001360 /* TwitterClient+MockTweet.swift */, 7199AE7926B96D0D001DEB46 /* NSRegularExpression+Convenience.swift */, + 710F03BE27B6700D00AE6C5B /* TwitterClientManager+CompressMedia.swift */, + 710F03C027B970FB00AE6C5B /* EnvironmentKeys+CornerRadius.swift */, ); path = Extensions; sourceTree = ""; @@ -225,7 +231,7 @@ isa = PBXGroup; children = ( 71800BF126906721009D11A1 /* ActionBarView.swift */, - 71A9157827A59C9000706024 /* AsyncLocalMediaPreview.swift */, + 71A9157827A59C9000706024 /* LocalMediaPreview.swift */, 7188E6522687D16C007CFD78 /* AttachmentThumbnail.swift */, 7188E6482687C0E0007CFD78 /* BroadcastButtonStyle.swift */, 71BBAAE8268CF1BD004048A0 /* ComposerView.swift */, @@ -389,6 +395,7 @@ 7188E6612688A01F007CFD78 /* Font.extension.swift in Sources */, 7188E62B2687B0FE007CFD78 /* ContentView.swift in Sources */, 717041F526B7037300001360 /* TweetView.swift in Sources */, + 710F03C127B970FB00AE6C5B /* EnvironmentKeys+CornerRadius.swift in Sources */, 717041F326B6FFEA00001360 /* RepliesListView.swift in Sources */, 7188E6292687B0FE007CFD78 /* BroadcastApp.swift in Sources */, 71B8290C268D0AC6002AEE72 /* TwitterClientManager.swift in Sources */, @@ -411,7 +418,7 @@ 71800BFF26999BA6009D11A1 /* PersistanceController.swift in Sources */, 71B82910268F2195002AEE72 /* LastTweetReplyView.swift in Sources */, 71800BF426906BC0009D11A1 /* DraftsListView.swift in Sources */, - 71A9157927A59C9000706024 /* AsyncLocalMediaPreview.swift in Sources */, + 71A9157927A59C9000706024 /* LocalMediaPreview.swift in Sources */, 71E36FFB2689EED40078D956 /* ShakeModifier.swift in Sources */, 71D283F127A469EF00640B2A /* AttributeScopes+TwitterEntities.swift in Sources */, 719087CF26891C7F005B96CE /* Array.extension.swift in Sources */, @@ -423,6 +430,7 @@ 71BBAAE9268CF1BD004048A0 /* ComposerView.swift in Sources */, 713E16EA27A6EAA900314B44 /* UserAvatar.swift in Sources */, 715AAE0C26C9589A002BCEA1 /* TestUtils.swift in Sources */, + 710F03BF27B6700D00AE6C5B /* TwitterClientManager+CompressMedia.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Broadcast.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Broadcast.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index db0b798..1d4f7c5 100644 --- a/Broadcast.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Broadcast.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -24,7 +24,7 @@ "repositoryURL": "https://github.com/daneden/Twift.git", "state": { "branch": "main", - "revision": "8b17110f0c85ac4db7d6c2d6912eb3ef443c73f6", + "revision": "30a21fca9a1d7399c4fb52cf0d04dfaceaa2d0ca", "version": null } }, diff --git a/Broadcast/ContentView.swift b/Broadcast/ContentView.swift index 5db3afc..6e227c7 100644 --- a/Broadcast/ContentView.swift +++ b/Broadcast/ContentView.swift @@ -11,6 +11,7 @@ import TwitterText import Twift struct ContentView: View { + @Environment(\.cornerRadius) var cornerRadius: Double @ScaledMetric private var captionSize: CGFloat = 20 @ScaledMetric private var bottomPadding: CGFloat = 80 @ScaledMetric private var replyBoxLimit: CGFloat = 96 @@ -54,7 +55,7 @@ struct ContentView: View { .padding() .frame(maxWidth: .infinity) .background(Color(.systemRed).opacity(0.2)) - .cornerRadius(captionSize) + .cornerRadius(cornerRadius) .onTapGesture { withAnimation { twitterClient.state = .idle @@ -71,7 +72,7 @@ struct ContentView: View { .animation(.springAnimation, value: imageHeightCompensation) AttachmentThumbnail(media: $twitterClient.selectedMedia) - .disabled(twitterClient.state == .busy()) + .disabled(twitterClient.state.isBusy) } else { WelcomeView() } diff --git a/Broadcast/Extensions/EnvironmentKeys+CornerRadius.swift b/Broadcast/Extensions/EnvironmentKeys+CornerRadius.swift new file mode 100644 index 0000000..2111fed --- /dev/null +++ b/Broadcast/Extensions/EnvironmentKeys+CornerRadius.swift @@ -0,0 +1,20 @@ +// +// EnvironmentKeys+CornerRadius.swift +// Broadcast +// +// Created by Daniel Eden on 13/02/2022. +// + +import Foundation +import SwiftUI + +struct CornerRadiusKey: EnvironmentKey { + static let defaultValue: Double = 12 +} + +extension EnvironmentValues { + var cornerRadius: Double { + get { self[CornerRadiusKey.self] } + set { self[CornerRadiusKey.self] = newValue } + } +} diff --git a/Broadcast/Extensions/TwitterClientManager+CompressMedia.swift b/Broadcast/Extensions/TwitterClientManager+CompressMedia.swift new file mode 100644 index 0000000..b716236 --- /dev/null +++ b/Broadcast/Extensions/TwitterClientManager+CompressMedia.swift @@ -0,0 +1,31 @@ +// +// TwitterClientManager+CompressMedia.swift +// Broadcast +// +// Created by Daniel Eden on 11/02/2022. +// + +import Foundation +import AVFoundation + +extension TwitterClientManager { + func compressVideo( + inputURL: URL, + outputURL: URL + ) async -> AVAssetExportSession? { + let urlAsset = AVURLAsset(url: inputURL, options: nil) + + guard let exportSession = AVAssetExportSession(asset: urlAsset, presetName: AVAssetExportPresetHighestQuality) else { + return nil + } + + exportSession.fileLengthLimit = 14 * 2^20 // 15mb limit + exportSession.timeRange = CMTimeRange(start: CMTime.zero, duration: urlAsset.duration) + exportSession.outputURL = outputURL + exportSession.outputFileType = AVFileType.mp4 + exportSession.shouldOptimizeForNetworkUse = true + + await exportSession.export() + return exportSession + } +} diff --git a/Broadcast/Helper Views/ActionBarView.swift b/Broadcast/Helper Views/ActionBarView.swift index f6328cc..8b7f07b 100644 --- a/Broadcast/Helper Views/ActionBarView.swift +++ b/Broadcast/Helper Views/ActionBarView.swift @@ -15,10 +15,17 @@ struct ActionBarView: View { @State private var photoPickerIsPresented = false + var loadingLabel: String? { + switch twitterClient.state { + case .busy(let label): return label + default: return nil + } + } + private var pickerConfig: PHPickerConfiguration { var config = PHPickerConfiguration(photoLibrary: .shared()) - config.preferredAssetRepresentationMode = .automatic + config.preferredAssetRepresentationMode = .compatible config.selection = .ordered if moreMediaAllowed && !twitterClient.selectedMedia.isEmpty { @@ -40,7 +47,7 @@ struct ActionBarView: View { var body: some View { publishingActions - .disabled(twitterClient.state == .busy()) + .disabled(twitterClient.state.isBusy) .sheet(isPresented: $photoPickerIsPresented) { ImagePicker(configuration: pickerConfig, selection: $twitterClient.selectedMedia) .ignoresSafeArea() @@ -76,7 +83,8 @@ struct ActionBarView: View { BroadcastButtonStyle( prominence: replying ? .primary : .secondary, isFullWidth: replying, - isLoading: twitterClient.state == .busy() && replying + isLoading: twitterClient.state.isBusy && replying, + loadingLabel: loadingLabel ) ) .disabled(replying && !twitterClient.draftIsValid()) @@ -105,7 +113,8 @@ struct ActionBarView: View { BroadcastButtonStyle( prominence: !replying ? .primary : .secondary, isFullWidth: !replying, - isLoading: twitterClient.state == .busy() && !replying + isLoading: twitterClient.state.isBusy && !replying, + loadingLabel: loadingLabel ) ) .disabled(!replying && !twitterClient.draftIsValid()) diff --git a/Broadcast/Helper Views/AttachmentThumbnail.swift b/Broadcast/Helper Views/AttachmentThumbnail.swift index 48b0816..f0d6fd1 100644 --- a/Broadcast/Helper Views/AttachmentThumbnail.swift +++ b/Broadcast/Helper Views/AttachmentThumbnail.swift @@ -13,6 +13,7 @@ extension String: Identifiable { } struct AttachmentThumbnail: View { + @Environment(\.cornerRadius) var cornerRadius @EnvironmentObject var twitterClient: TwitterClientManager @Binding var media: [String: NSItemProvider] @State private var altTextSheetIsPresented = false @@ -23,8 +24,8 @@ struct AttachmentThumbnail: View { if let media = media, !media.isEmpty { ForEach(Array(media.keys), id: \.self) { key in ZStack(alignment: .top) { - AsyncLocalMediaPreview(assetId: key, asset: media[key]!) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + LocalMediaPreview(assetId: key, asset: media[key]!) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) HStack { Button(action: { removeImage(key) }) { @@ -69,8 +70,9 @@ struct AttachmentThumbnail: View { } fileprivate struct AltTextSheet: View { - @EnvironmentObject var twitterClient: TwitterClientManager + @Environment(\.cornerRadius) var cornerRadius: Double @Environment(\.presentationMode) var presentationMode + @EnvironmentObject var twitterClient: TwitterClientManager var assetId: String var asset: NSItemProvider @@ -82,8 +84,8 @@ fileprivate struct AltTextSheet: View { Form { HStack { Spacer() - AsyncLocalMediaPreview(assetId: assetId, asset: asset) - .cornerRadius(8) + LocalMediaPreview(assetId: assetId, asset: asset) + .cornerRadius(cornerRadius / 2) .frame(minWidth: 0, maxWidth: 200, minHeight: 0, maxHeight: 200) Spacer() } diff --git a/Broadcast/Helper Views/BroadcastButtonStyle.swift b/Broadcast/Helper Views/BroadcastButtonStyle.swift index 4fe2787..59ca665 100644 --- a/Broadcast/Helper Views/BroadcastButtonStyle.swift +++ b/Broadcast/Helper Views/BroadcastButtonStyle.swift @@ -38,6 +38,7 @@ struct BroadcastButtonStyle: ButtonStyle { var prominence: Prominence = .primary var isFullWidth = true var isLoading = false + var loadingLabel: String? var background: some View { Group { @@ -83,7 +84,14 @@ struct BroadcastButtonStyle: ButtonStyle { .overlay( Group { if isLoading { - ProgressView().tint(foregroundColor) + HStack(spacing: 8) { + ProgressView().tint(foregroundColor) + if let loadingLabel = loadingLabel { + Text(loadingLabel) + .foregroundColor(foregroundColor) + .font(.broadcastBody.bold()) + } + } } } ) diff --git a/Broadcast/Helper Views/ComposerView.swift b/Broadcast/Helper Views/ComposerView.swift index 09490f7..d81bb39 100644 --- a/Broadcast/Helper Views/ComposerView.swift +++ b/Broadcast/Helper Views/ComposerView.swift @@ -20,6 +20,7 @@ fileprivate let placeholderCandidates: [String] = [ ] struct ComposerView: View { + @Environment(\.cornerRadius) var cornerRadius: Double let debouncer = Debouncer(timeInterval: 0.3) @EnvironmentObject var twitterClient: TwitterClientManager @@ -133,7 +134,7 @@ struct ComposerView: View { .multilineTextAlignment(.trailing) } } - .disabled(twitterClient.state == .busy()) + .disabled(twitterClient.state.isBusy) .padding() .background(.thinMaterial) .onShake { @@ -177,7 +178,13 @@ struct ComposerView: View { .foregroundStyle(.tint) } } - .onDrop(of: [.image], delegate: AttachmentDropDelegate(dropActive: $dropActive, twitterClient: twitterClient)) + .onDrop( + of: [.image], + delegate: AttachmentDropDelegate( + dropActive: $dropActive, + twitterClient: twitterClient + ) + ) if let users = mentionCandidates, !users.isEmpty, @@ -187,7 +194,7 @@ struct ComposerView: View { completeMention(user) } } - }.cornerRadius(captionSize) + }.cornerRadius(cornerRadius) } func completeMention(_ user: User) { diff --git a/Broadcast/Helper Views/DraftsListView.swift b/Broadcast/Helper Views/DraftsListView.swift index 10570c5..5f9c420 100644 --- a/Broadcast/Helper Views/DraftsListView.swift +++ b/Broadcast/Helper Views/DraftsListView.swift @@ -40,17 +40,6 @@ struct DraftsListView: View { Text("Empty Draft").foregroundColor(.secondary) } } - - Spacer() - -// if let imageData = draft.media, -// let image = UIImage(data: imageData) { -// Image(uiImage: image) -// .resizable() -// .aspectRatio(contentMode: .fill) -// .frame(width: thumbnailSize, height: thumbnailSize) -// .cornerRadius(8) -// } } .contentShape(Rectangle()) .onTapGesture { diff --git a/Broadcast/Helper Views/LastTweetReplyView.swift b/Broadcast/Helper Views/LastTweetReplyView.swift index fa5cd96..a96b2e4 100644 --- a/Broadcast/Helper Views/LastTweetReplyView.swift +++ b/Broadcast/Helper Views/LastTweetReplyView.swift @@ -9,6 +9,7 @@ import SwiftUI import Twift struct LastTweetReplyView: View { + @Environment(\.cornerRadius) var cornerRadius: Double @ScaledMetric var spacing: CGFloat = 4 var lastTweet: Tweet @@ -38,7 +39,7 @@ struct LastTweetReplyView: View { } .padding(spacing * 2) .background(Color.accentColor.opacity(0.1)) - .cornerRadius(spacing * 2) + .cornerRadius(cornerRadius) } } diff --git a/Broadcast/Helper Views/AsyncLocalMediaPreview.swift b/Broadcast/Helper Views/LocalMediaPreview.swift similarity index 98% rename from Broadcast/Helper Views/AsyncLocalMediaPreview.swift rename to Broadcast/Helper Views/LocalMediaPreview.swift index 0644964..adbb092 100644 --- a/Broadcast/Helper Views/AsyncLocalMediaPreview.swift +++ b/Broadcast/Helper Views/LocalMediaPreview.swift @@ -10,7 +10,7 @@ import PhotosUI import AVKit import QuickLook -struct AsyncLocalMediaPreview: View { +struct LocalMediaPreview: View { private enum PreviewLoadingState { case loadedImage(_ image: UIImage) case loadedVideo(_ video: AVPlayer) @@ -44,7 +44,6 @@ struct AsyncLocalMediaPreview: View { case .loadedVideo(let player): VideoPlayer(player: player) .scaledToFit() - .fixedSize() case .failed: Label("Cannot Load Preview", systemImage: "eye.slash") .foregroundStyle(.secondary) diff --git a/Broadcast/Helper Views/MentionBar.swift b/Broadcast/Helper Views/MentionBar.swift index 793e6c7..4c14e87 100644 --- a/Broadcast/Helper Views/MentionBar.swift +++ b/Broadcast/Helper Views/MentionBar.swift @@ -9,6 +9,7 @@ import SwiftUI import Twift struct MentionBar: View { + @Environment(\.cornerRadius) var cornerRadius: Double var users: [User] var tapHandler: (User) -> Void = { _ in } @@ -19,7 +20,7 @@ struct MentionBar: View { UserView(user: user) .padding(8) .background(.regularMaterial) - .cornerRadius(6) + .cornerRadius(cornerRadius / 2) .shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 6) .onTapGesture { tapHandler(user) diff --git a/Broadcast/Helpers/TwitterClientManager.swift b/Broadcast/Helpers/TwitterClientManager.swift index 1129a73..22a2ef0 100644 --- a/Broadcast/Helpers/TwitterClientManager.swift +++ b/Broadcast/Helpers/TwitterClientManager.swift @@ -14,6 +14,7 @@ import AuthenticationServices import SwiftKeychainWrapper import SwiftUI import PhotosUI +import CryptoKit let typeaheadToken = "AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA" @@ -173,8 +174,7 @@ class TwitterClientManager: ObservableObject { var mediaStrings: [String] = [] for (key, media) in selectedMedia { - - let media: (Data, String)? = await withUnsafeContinuation { continuation in + let media: (Data?, URL?, String)? = await withUnsafeContinuation { continuation in var utType: UTType if media.hasItemConformingToTypeIdentifier(UTType.image.identifier) { @@ -189,25 +189,67 @@ class TwitterClientManager: ObservableObject { return continuation.resume(returning: nil) } - media.loadDataRepresentation(forTypeIdentifier: utType.identifier, completionHandler: { data, error in - if let error = error { - print(error) - return self.sendTweetCallback(response: nil, error: error) + if utType.conforms(to: .movie) { + media.loadFileRepresentation(forTypeIdentifier: utType.identifier) { url, error in + if let error = error { + return self.sendTweetCallback(response: nil, error: error) + } + + if let url = url { + let outputUrl = URL(fileURLWithPath: NSTemporaryDirectory() + UUID().uuidString + ".mp4") + let fileData = try? Data(contentsOf: url) + try? fileData?.write(to: outputUrl) + continuation.resume(returning: (nil, outputUrl, mimeTypeString)) + } else { + return self.updateState(.genericTextAndMediaError) + } } - - if let data = data { - continuation.resume(returning: (data, mimeTypeString)) - } else { - return self.updateState(.error("There was a problem Tweeting the attached media because it's in an unusual format.")) + } else { + media.loadDataRepresentation(forTypeIdentifier: utType.identifier) { data, error in + if let error = error { + return self.sendTweetCallback(response: nil, error: error) + } + + if let data = data { + continuation.resume(returning: (data, nil, mimeTypeString)) + } else { + return self.updateState(.error("There was a problem Tweeting the attached media because it's in an unusual format.")) + } } - }) + } } - guard let (data, mimeType) = media else { + guard let (data, url, mimeType) = media else { return updateState(.genericTextAndMediaError) } - let result = try await client.upload(mediaData: data, mimeType: mimeType, progress: &self.uploadProgress) + var finalData: Data! + if let url = url { + self.updateState(.busy("Compressing")) + let outputUrl = URL(fileURLWithPath: NSTemporaryDirectory() + UUID().uuidString + ".mp4") + let exportSession: AVAssetExportSession? = await compressVideo(inputURL: url, outputURL: outputUrl) + + guard let url = exportSession?.outputURL, + let data = try? Data(contentsOf: url) else { + return self.updateState(.genericTextAndMediaError) + } + finalData = data + } else if let data = data { + finalData = data + } + + var category: MediaCategory + + if mimeType.contains("gif") { + category = .tweetGif + } else if mimeType.contains("video") { + category = .tweetVideo + } else { + category = .tweetImage + } + + self.updateState(.busy("Uploading")) + let result = try await client.upload(mediaData: finalData, mimeType: mimeType, category: category) if let altText = mediaAltText[key] { try await client.addAltText(to: result.mediaIdString, text: altText) @@ -224,11 +266,11 @@ class TwitterClientManager: ObservableObject { draft.media = MutableMedia(mediaIds: mediaStrings) } + self.updateState(.busy("Posting")) let result = try await client.postTweet(draft) self.lastTweet = try await client.getTweet(result.data.id).data sendTweetCallback(response: result, error: nil) } catch { - print(error) sendTweetCallback(response: nil, error: error) } } @@ -376,11 +418,18 @@ extension TwitterClientManager { extension TwitterClientManager { enum State: Equatable { case idle, initializing - case busy(_ progress: Progress? = nil) + case busy(_ label: String? = nil) case error(_: String? = nil) static var genericTextError = State.error("Oh man, something went wrong sending that tweet. It might be too long.") static var genericTextAndMediaError = State.error("Oh man, something went wrong sending that tweet. Maybe it’s too long, or your chosen media is causing a problem.") + + var isBusy: Bool { + switch self { + case .busy(_): return true + default: return false + } + } } struct ClientCredentials {