├── .gitignore ├── Broadcast ├── Assets.xcassets │ ├── Contents.json │ ├── AppIcon.appiconset │ │ ├── app-icon-1024@1x.png │ │ ├── app-icon-20@1x.png │ │ ├── app-icon-20@2x-1.png │ │ ├── app-icon-20@2x.png │ │ ├── app-icon-20@3x.png │ │ ├── app-icon-29@1x-1.png │ │ ├── app-icon-29@1x.png │ │ ├── app-icon-29@2x-1.png │ │ ├── app-icon-29@2x.png │ │ ├── app-icon-29@3x.png │ │ ├── app-icon-40@1x.png │ │ ├── app-icon-40@2x-1.png │ │ ├── app-icon-40@2x.png │ │ ├── app-icon-40@3x.png │ │ ├── app-icon-60@2x.png │ │ ├── app-icon-60@3x.png │ │ ├── app-icon-76@1x.png │ │ ├── app-icon-76@2x.png │ │ ├── app-icon-83.5@2x.png │ │ └── Contents.json │ ├── twitter.fill.symbolset │ │ └── Contents.json │ ├── AccentColor.colorset │ │ └── Contents.json │ └── twitterBrandColor.colorset │ │ └── Contents.json ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── Helpers │ ├── TestUtils.swift │ ├── Binding.extension.swift │ ├── Debouncer.swift │ ├── ShakeModifier.swift │ ├── PersistanceController.swift │ ├── ThemeHelper.swift │ ├── Haptics.swift │ └── TwitterClient.swift ├── Extensions │ ├── String.extension.swift │ ├── Array.extension.swift │ ├── Notification.extension.swift │ ├── Animation.extension.swift │ ├── UIApplication.extension.swift │ ├── UIImage.extension.swift │ ├── Font.extension.swift │ ├── TwitterClient+MockTweet.swift │ └── NSRegularExpression+Convenience.swift ├── TwitterAPI-Info.example.plist ├── Helper Views │ ├── VisualEffectView.swift │ ├── EngagementCountersView.swift │ ├── UserView.swift │ ├── MentionBar.swift │ ├── NullStateView.swift │ ├── RepliesListView.swift │ ├── AttachmentThumbnail.swift │ ├── PhotoPicker.swift │ ├── LastTweetReplyView.swift │ ├── RemoteImage.swift │ ├── SafariView.swift │ ├── TweetView.swift │ ├── BroadcastButtonStyle.swift │ ├── DraftsListView.swift │ ├── ActionBarView.swift │ └── ComposerView.swift ├── DraftsModel.xcdatamodeld │ └── DraftsModel.xcdatamodel │ │ └── contents ├── BroadcastApp.swift ├── WelcomeView.swift ├── Info.plist ├── SignOutView.swift └── ContentView.swift ├── README.md ├── Broadcast.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved ├── xcuserdata │ └── dte.xcuserdatad │ │ ├── xcdebugger │ │ └── Breakpoints_v2.xcbkptlist │ │ └── xcschemes │ │ └── xcschememanagement.plist ├── xcshareddata │ └── xcschemes │ │ └── Broadcast.xcscheme └── project.pbxproj ├── PRIVACY.md ├── BroadcastUITests ├── Info.plist └── BroadcastUITests.swift └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | TwitterAPI-Info.plist 2 | -------------------------------------------------------------------------------- /Broadcast/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Broadcast 2 | 3 | Broadcast is a write-only Twitter client. It lets you post tweets and reply to your last tweet, and that's it. 4 | -------------------------------------------------------------------------------- /Broadcast/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-1024@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneden/Broadcast/HEAD/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-1024@1x.png -------------------------------------------------------------------------------- /Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneden/Broadcast/HEAD/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-20@1x.png -------------------------------------------------------------------------------- /Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-20@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneden/Broadcast/HEAD/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-20@2x-1.png -------------------------------------------------------------------------------- /Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneden/Broadcast/HEAD/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-20@2x.png -------------------------------------------------------------------------------- /Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneden/Broadcast/HEAD/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-20@3x.png -------------------------------------------------------------------------------- /Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-29@1x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneden/Broadcast/HEAD/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-29@1x-1.png -------------------------------------------------------------------------------- /Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-29@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneden/Broadcast/HEAD/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-29@1x.png -------------------------------------------------------------------------------- /Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-29@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneden/Broadcast/HEAD/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-29@2x-1.png -------------------------------------------------------------------------------- /Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneden/Broadcast/HEAD/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-29@2x.png -------------------------------------------------------------------------------- /Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneden/Broadcast/HEAD/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-29@3x.png -------------------------------------------------------------------------------- /Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-40@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneden/Broadcast/HEAD/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-40@1x.png -------------------------------------------------------------------------------- /Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-40@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneden/Broadcast/HEAD/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-40@2x-1.png -------------------------------------------------------------------------------- /Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneden/Broadcast/HEAD/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-40@2x.png -------------------------------------------------------------------------------- /Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneden/Broadcast/HEAD/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-40@3x.png -------------------------------------------------------------------------------- /Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneden/Broadcast/HEAD/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-60@2x.png -------------------------------------------------------------------------------- /Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneden/Broadcast/HEAD/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-60@3x.png -------------------------------------------------------------------------------- /Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneden/Broadcast/HEAD/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-76@1x.png -------------------------------------------------------------------------------- /Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneden/Broadcast/HEAD/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-76@2x.png -------------------------------------------------------------------------------- /Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneden/Broadcast/HEAD/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-83.5@2x.png -------------------------------------------------------------------------------- /Broadcast.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Broadcast.xcodeproj/xcuserdata/dte.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | -------------------------------------------------------------------------------- /Broadcast/Assets.xcassets/twitter.fill.symbolset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | }, 6 | "symbols" : [ 7 | { 8 | "filename" : "twitter.fill.svg", 9 | "idiom" : "universal" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /Broadcast/Helpers/TestUtils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestUtils.swift 3 | // Broadcast 4 | // 5 | // Created by Daniel Eden on 15/08/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | var isTestEnvironment: Bool { 11 | ProcessInfo.processInfo.arguments.contains("isTestEnvironment") 12 | } 13 | -------------------------------------------------------------------------------- /Broadcast/Extensions/String.extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String.extension.swift 3 | // Broadcast 4 | // 5 | // Created by Daniel Eden on 26/06/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | extension String { 11 | var isBlank: Bool { 12 | return allSatisfy({ $0.isWhitespace }) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Broadcast/Extensions/Array.extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Array.extension.swift 3 | // Broadcast 4 | // 5 | // Created by Daniel Eden on 27/06/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Array { 11 | func randomElement() -> Element { 12 | return self[Int.random(in: 0...(count-1))] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Broadcast.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Broadcast/Extensions/Notification.extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Notification.extension.swift 3 | // Broadcast 4 | // 5 | // Created by Daniel Eden on 26/06/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Notification.Name { 11 | static let twitterCallback = Notification.Name(rawValue: "Twitter.CallbackNotification.Name") 12 | } 13 | -------------------------------------------------------------------------------- /Broadcast/Extensions/Animation.extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Globals.swift 3 | // Broadcast 4 | // 5 | // Created by Daniel Eden on 02/07/2021. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | extension Animation { 12 | static let springAnimation = Animation.interactiveSpring(response: 0.4, dampingFraction: 0.7, blendDuration: 0.4) 13 | } 14 | -------------------------------------------------------------------------------- /Broadcast/Extensions/UIApplication.extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIApplication.extension.swift 3 | // Broadcast 4 | // 5 | // Created by Daniel Eden on 27/06/2021. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | extension UIApplication { 12 | func endEditing() { 13 | sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Broadcast/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "242", 9 | "green" : "161", 10 | "red" : "29" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Broadcast/Assets.xcassets/twitterBrandColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.949", 9 | "green" : "0.631", 10 | "red" : "0.114" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /PRIVACY.md: -------------------------------------------------------------------------------- 1 | # Broadcast Privacy Policy 2 | 3 | We do not collect any of your data. All data retrieved from Twitter’s API is kept local to your device and is immediately removed when signing out from or deleting the app. 4 | 5 | We don't display any ads, and never will. We don't track you using an advertising identifier or anything of the sort. 6 | 7 | If you have any issues to report or feedback about this privacy policy or anything else, you can do it using GitHub issues on this repository. 8 | -------------------------------------------------------------------------------- /Broadcast/TwitterAPI-Info.example.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | __TEST_AUTH_QUERY_STRING 6 | Enter auth query string here (necessary for tests) 7 | API_SECRET 8 | Enter API Secret Here 9 | API_KEY 10 | Enter API Key Here 11 | 12 | 13 | -------------------------------------------------------------------------------- /Broadcast/Helper Views/VisualEffectView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VisualEffectView.swift 3 | // Broadcast 4 | // 5 | // Created by Daniel Eden on 27/06/2021. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | struct VisualEffectView: UIViewRepresentable { 12 | var effect: UIVisualEffect? 13 | func makeUIView(context: UIViewRepresentableContext) -> UIVisualEffectView { UIVisualEffectView() } 14 | func updateUIView(_ uiView: UIVisualEffectView, context: UIViewRepresentableContext) { uiView.effect = effect } 15 | } 16 | -------------------------------------------------------------------------------- /Broadcast/Helpers/Binding.extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Binding.extension.swift 3 | // Broadcast 4 | // 5 | // Created by Daniel Eden on 26/06/2021. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | public extension Binding where Value: Equatable { 12 | init(_ source: Binding, replacingNilWith nilProxy: Value) { 13 | self.init( 14 | get: { source.wrappedValue ?? nilProxy }, 15 | set: { newValue in 16 | if newValue == nilProxy { 17 | source.wrappedValue = nil 18 | } 19 | else { 20 | source.wrappedValue = newValue 21 | } 22 | }) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Broadcast/Extensions/UIImage.extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIImage.extension.swift 3 | // Broadcast 4 | // 5 | // Created by Daniel Eden on 10/07/2021. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | extension UIImage { 12 | func fixOrientation(_ img: UIImage) -> UIImage { 13 | if (img.imageOrientation == .up) { 14 | return img 15 | } 16 | 17 | UIGraphicsBeginImageContextWithOptions(img.size, false, img.scale) 18 | let rect = CGRect(x: 0, y: 0, width: img.size.width, height: img.size.height) 19 | img.draw(in: rect) 20 | 21 | let normalizedImage = UIGraphicsGetImageFromCurrentImageContext()! 22 | UIGraphicsEndImageContext() 23 | 24 | return normalizedImage 25 | } 26 | 27 | var fixedOrientation: UIImage { 28 | return fixOrientation(self) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /BroadcastUITests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Broadcast/Extensions/Font.extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Font.extension.swift 3 | // Broadcast 4 | // 5 | // Created by Daniel Eden on 27/06/2021. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | extension Font { 12 | static var broadcastBody: Font = .system(.body, design: .rounded) 13 | static var broadcastTitle: Font = .system(.title, design: .rounded) 14 | static var broadcastTitle2: Font = .system(.title2, design: .rounded) 15 | static var broadcastTitle3: Font = .system(.title3, design: .rounded) 16 | static var broadcastLargeTitle: Font = .system(.largeTitle, design: .rounded) 17 | static var broadcastFootnote: Font = .system(.footnote, design: .rounded) 18 | static var broadcastCaption: Font = .system(.caption, design: .rounded) 19 | static var broadcastHeadline: Font = .system(.headline, design: .rounded) 20 | } 21 | -------------------------------------------------------------------------------- /Broadcast/Extensions/TwitterClient+MockTweet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TwitterClient+MockTweet.swift 3 | // Broadcast 4 | // 5 | // Created by Daniel Eden on 01/08/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | extension TwitterClient.Tweet { 11 | static var mockTweet: TwitterClient.Tweet { 12 | TwitterClient.Tweet( 13 | numericId: 0, 14 | id: "0", 15 | text: "just setting up my twttr", 16 | likes: 420, 17 | retweets: 69, 18 | date: Date(), 19 | author: .mockUser 20 | ) 21 | } 22 | } 23 | 24 | extension TwitterClient.User { 25 | static var mockUser: TwitterClient.User { 26 | TwitterClient.User( 27 | id: "0", 28 | screenName: "_dte", 29 | originalProfileImageURL: URL(string: "https://pbs.twimg.com/profile_images/1337359860409790469/javRMXyG_x96.jpg")! 30 | ) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Broadcast.xcodeproj/xcuserdata/dte.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | Broadcast.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | BroadcastTests.xcscheme_^#shared#^_ 13 | 14 | orderHint 15 | 1 16 | 17 | 18 | SuppressBuildableAutocreation 19 | 20 | 711EF99026C959A700FD8A9F 21 | 22 | primary 23 | 24 | 25 | 7188E6242687B0FE007CFD78 26 | 27 | primary 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /Broadcast/Helpers/Debouncer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Debouncer.swift 3 | // Broadcast 4 | // 5 | // Created by Daniel Eden on 03/08/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | public class Debouncer { 11 | private let timeInterval: TimeInterval 12 | private var timer: Timer? 13 | 14 | typealias Handler = () -> Void 15 | var handler: Handler? 16 | 17 | init(timeInterval: TimeInterval) { 18 | self.timeInterval = timeInterval 19 | } 20 | 21 | public func renewInterval() { 22 | timer?.invalidate() 23 | timer = Timer.scheduledTimer(withTimeInterval: timeInterval, repeats: false, block: { [weak self] (timer) in 24 | self?.timeIntervalDidFinish(for: timer) 25 | }) 26 | } 27 | 28 | @objc private func timeIntervalDidFinish(for timer: Timer) { 29 | guard timer.isValid else { 30 | return 31 | } 32 | 33 | handler?() 34 | handler = nil 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Broadcast/DraftsModel.xcdatamodeld/DraftsModel.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /Broadcast/Helper Views/EngagementCountersView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EngagementCountersView.swift 3 | // Broadcast 4 | // 5 | // Created by Daniel Eden on 03/07/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct EngagementCountersView: View { 11 | var tweet: TwitterClient.Tweet 12 | 13 | var repliesString: String { 14 | let replyCount = tweet.replies?.count ?? 0 15 | switch replyCount { 16 | case 0: 17 | return "No replies" 18 | case 1: 19 | return "1 Reply" 20 | default: 21 | return "\(replyCount) Replies" 22 | } 23 | } 24 | 25 | var body: some View { 26 | Label(repliesString, systemImage: "arrowshape.turn.up.left") 27 | .font(.broadcastCaption) 28 | .foregroundColor(.accentColor) 29 | } 30 | } 31 | 32 | struct EngagementCountersView_Previews: PreviewProvider { 33 | static var previews: some View { 34 | EngagementCountersView(tweet: TwitterClient.Tweet(likes: 420, retweets: 69, replies: [])) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Broadcast/Helper Views/UserView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserView.swift 3 | // Broadcast 4 | // 5 | // Created by Daniel Eden on 03/08/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct UserView: View { 11 | @ScaledMetric var avatarSize: CGFloat = 24 12 | 13 | var user: TwitterClient.User 14 | var body: some View { 15 | HStack { 16 | if let imageUrl = user.profileImageURL { 17 | RemoteImage(url: imageUrl, placeholder: { ProgressView() }) 18 | .aspectRatio(contentMode: .fill) 19 | .frame(width: avatarSize, height: avatarSize) 20 | .cornerRadius(36) 21 | } 22 | 23 | VStack(alignment: .leading) { 24 | if let name = user.name { 25 | Text(name) 26 | .fontWeight(.bold) 27 | } 28 | 29 | Text("@\(user.screenName)") 30 | .foregroundColor(.secondary) 31 | } 32 | }.font(.broadcastFootnote) 33 | } 34 | } 35 | 36 | struct UserView_Previews: PreviewProvider { 37 | static var previews: some View { 38 | UserView(user: .mockUser) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Broadcast/Helper Views/MentionBar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MentionBar.swift 3 | // Broadcast 4 | // 5 | // Created by Daniel Eden on 03/08/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct MentionBar: View { 11 | var users: [TwitterClient.User] 12 | var tapHandler: (TwitterClient.User) -> Void = { _ in } 13 | 14 | var body: some View { 15 | ScrollView(.horizontal) { 16 | HStack { 17 | ForEach(users, id: \.id) { user in 18 | UserView(user: user) 19 | .padding(8) 20 | .background(VisualEffectView(effect: UIBlurEffect(style: .systemMaterial))) 21 | .cornerRadius(6) 22 | .shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 6) 23 | .onTapGesture { 24 | tapHandler(user) 25 | } 26 | } 27 | }.padding(12) 28 | } 29 | .background(VisualEffectView(effect: UIBlurEffect(style: .prominent))) 30 | } 31 | } 32 | 33 | struct MentionBar_Previews: PreviewProvider { 34 | static var previews: some View { 35 | MentionBar(users: [.mockUser]) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Daniel Eden 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Broadcast/Helper Views/NullStateView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NullStateView.swift 3 | // Broadcast 4 | // 5 | // Created by Daniel Eden on 14/08/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct NullStateView: View { 11 | enum ViewType { 12 | case replies, drafts 13 | } 14 | 15 | var type: ViewType 16 | 17 | var imageName: String { 18 | switch type { 19 | case .drafts: 20 | return "doc.on.doc" 21 | case .replies: 22 | return "arrowshape.turn.up.left" 23 | } 24 | } 25 | 26 | var label: String { 27 | switch type { 28 | case .drafts: 29 | return "No Drafts" 30 | case .replies: 31 | return "No Replies" 32 | } 33 | } 34 | 35 | var body: some View { 36 | VStack { 37 | Spacer() 38 | VStack(spacing: 8) { 39 | Image(systemName: imageName) 40 | .imageScale(.large) 41 | Text(label) 42 | } 43 | .font(.broadcastTitle2) 44 | .foregroundColor(.secondary) 45 | Spacer() 46 | } 47 | } 48 | } 49 | 50 | struct NullStateView_Previews: PreviewProvider { 51 | static var previews: some View { 52 | NullStateView(type: .drafts) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Broadcast/BroadcastApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BroadcastApp.swift 3 | // Broadcast 4 | // 5 | // Created by Daniel Eden on 26/06/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct BroadcastApp: App { 12 | @Environment(\.scenePhase) var scenePhase 13 | @StateObject var themeHelper = ThemeHelper.shared 14 | @StateObject var twitterClient = TwitterClient() 15 | let persistenceController = PersistanceController.shared 16 | 17 | var body: some Scene { 18 | WindowGroup { 19 | GeometryReader { geom in 20 | ZStack(alignment: .top) { 21 | ContentView() 22 | .environmentObject(twitterClient) 23 | .environmentObject(themeHelper) 24 | .environment(\.managedObjectContext, persistenceController.container.viewContext) 25 | .accentColor(themeHelper.color) 26 | .onChange(of: scenePhase) { newPhase in 27 | if newPhase == .active { 28 | twitterClient.revalidateAccount() 29 | } 30 | 31 | persistenceController.save() 32 | } 33 | 34 | VisualEffectView(effect: UIBlurEffect(style: .regular)) 35 | .frame(height: geom.safeAreaInsets.top) 36 | .ignoresSafeArea(.all, edges: .top) 37 | } 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Broadcast/Helper Views/RepliesListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RepliesListView.swift 3 | // Broadcast 4 | // 5 | // Created by Daniel Eden on 01/08/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct RepliesListView: View { 11 | @Environment(\.presentationMode) var presentationMode 12 | var tweet: TwitterClient.Tweet? 13 | 14 | var body: some View { 15 | NavigationView { 16 | Group { 17 | if let tweet = tweet, let replies = tweet.replies, !replies.isEmpty { 18 | List { 19 | ForEach(replies, id: \.id) { reply in 20 | TweetView(tweet: reply) 21 | .onTapGesture { 22 | guard let screenName = reply.author?.screenName, 23 | let tweetId = reply.id else { return } 24 | let url = URL(string: "https://twitter.com/\(screenName)/status/\(tweetId)") 25 | 26 | UIApplication.shared.open(url!) 27 | } 28 | } 29 | } 30 | } else { 31 | NullStateView(type: .replies) 32 | } 33 | }.navigationTitle("Replies") 34 | .toolbar { 35 | Button("Close") { 36 | presentationMode.wrappedValue.dismiss() 37 | } 38 | } 39 | } 40 | } 41 | } 42 | 43 | struct RepliesListView_Previews: PreviewProvider { 44 | static var previews: some View { 45 | RepliesListView() 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Broadcast/Helper Views/AttachmentThumbnail.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThumbnailFilmstrip.swift 3 | // Broadcast 4 | // 5 | // Created by Daniel Eden on 26/06/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct AttachmentThumbnail: View { 11 | @Binding var image: UIImage? 12 | 13 | var body: some View { 14 | Group { 15 | if let image = image { 16 | ZStack(alignment: .topTrailing) { 17 | Image(uiImage: image) 18 | .resizable() 19 | .aspectRatio(image.size, contentMode: .fill) 20 | .clipShape(RoundedRectangle(cornerRadius: 8)) 21 | 22 | Button(action: removeImage) { 23 | Label("Remove Image", systemImage: "xmark.circle") 24 | .labelStyle(IconOnlyLabelStyle()) 25 | .font(.broadcastTitle.bold()) 26 | .foregroundColor(.white) 27 | .shadow(color: .black, radius: 8, x: 0, y: 4) 28 | } 29 | .buttonStyle(BroadcastButtonStyle(paddingSize: -2, prominence: .tertiary, isFullWidth: false)) 30 | .clipShape(Circle()) 31 | .offset(x: -8, y: 8) 32 | } 33 | } 34 | }.transition(.opacity) 35 | 36 | } 37 | 38 | func removeImage() { 39 | withAnimation { image = nil } 40 | } 41 | } 42 | 43 | struct ThumbnailFilmstrip_Previews: PreviewProvider { 44 | static var previews: some View { 45 | AttachmentThumbnail(image: .constant(nil)) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Broadcast/Helpers/ShakeModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShakeModifier.swift 3 | // Broadcast 4 | // 5 | // Created by Daniel Eden on 28/06/2021. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | import SwiftUI 11 | 12 | // The notification we'll send when a shake gesture happens. 13 | extension UIDevice { 14 | static let deviceDidShakeNotification = Notification.Name(rawValue: "deviceDidShakeNotification") 15 | } 16 | 17 | // Override the default behavior of shake gestures to send our notification instead. 18 | extension UIWindow { 19 | open override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) { 20 | if motion == .motionShake { 21 | NotificationCenter.default.post(name: UIDevice.deviceDidShakeNotification, object: nil) 22 | } 23 | } 24 | } 25 | 26 | // A view modifier that detects shaking and calls a function of our choosing. 27 | struct DeviceShakeViewModifier: ViewModifier { 28 | let action: () -> Void 29 | 30 | func body(content: Content) -> some View { 31 | content 32 | .onAppear() 33 | .onReceive(NotificationCenter.default.publisher(for: UIDevice.deviceDidShakeNotification)) { _ in 34 | action() 35 | } 36 | } 37 | } 38 | 39 | // A View extension to make the modifier easier to use. 40 | extension View { 41 | func onShake(perform action: @escaping () -> Void) -> some View { 42 | self.modifier(DeviceShakeViewModifier(action: action)) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Broadcast/Helper Views/PhotoPicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PhotoPicker.swift 3 | // Broadcast 4 | // 5 | // Created by Daniel Eden on 26/06/2021. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | import SwiftUI 11 | 12 | struct ImagePicker: UIViewControllerRepresentable { 13 | @Environment(\.presentationMode) var presentationMode 14 | @Binding var chosenImage: UIImage? 15 | 16 | func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIImagePickerController { 17 | let picker = UIImagePickerController() 18 | picker.delegate = context.coordinator 19 | return picker 20 | } 21 | 22 | func updateUIViewController(_ uiViewController: UIImagePickerController, context: UIViewControllerRepresentableContext) { 23 | 24 | } 25 | 26 | func makeCoordinator() -> Coordinator { 27 | Coordinator(self) 28 | } 29 | 30 | class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { 31 | let parent: ImagePicker 32 | 33 | init(_ parent: ImagePicker) { 34 | self.parent = parent 35 | } 36 | 37 | func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { 38 | if let image = info[.originalImage] as? UIImage { 39 | parent.chosenImage = image 40 | } 41 | 42 | parent.presentationMode.wrappedValue.dismiss() 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Broadcast/Helper Views/LastTweetReplyView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LastTweetReplyView.swift 3 | // Broadcast 4 | // 5 | // Created by Daniel Eden on 02/07/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct LastTweetReplyView: View { 11 | @ScaledMetric var spacing: CGFloat = 4 12 | var lastTweet: TwitterClient.Tweet 13 | 14 | var body: some View { 15 | VStack(alignment: .leading, spacing: spacing) { 16 | HStack(spacing: 0) { 17 | Label(title: { 18 | VStack(alignment: .leading, spacing: spacing) { 19 | Text("Replying to last tweet") 20 | .fontWeight(.semibold) 21 | .foregroundColor(.accentColor) 22 | if let tweetText = lastTweet.text { 23 | Text(tweetText) 24 | .foregroundColor(.secondary) 25 | } 26 | 27 | EngagementCountersView(tweet: lastTweet) 28 | } 29 | }, icon: { 30 | Image(systemName: "arrowshape.turn.up.left.fill") 31 | .foregroundColor(.accentColor) 32 | }) 33 | .font(.broadcastFootnote) 34 | 35 | Spacer(minLength: 0) 36 | } 37 | } 38 | .padding(spacing * 2) 39 | .background(Color.accentColor.opacity(0.1)) 40 | .cornerRadius(spacing * 2) 41 | } 42 | } 43 | 44 | struct LastTweetReplyView_Previews: PreviewProvider { 45 | static var previews: some View { 46 | LastTweetReplyView(lastTweet: TwitterClient.Tweet(text: "Example tweet")) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Broadcast/Helpers/PersistanceController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PersistanceController.swift 3 | // Broadcast 4 | // 5 | // Created by Daniel Eden on 10/07/2021. 6 | // 7 | 8 | import Foundation 9 | import CoreData 10 | 11 | struct PersistanceController { 12 | static let shared = PersistanceController() 13 | 14 | let container: NSPersistentContainer 15 | var context: NSManagedObjectContext { 16 | container.viewContext 17 | } 18 | 19 | static var preview: PersistanceController { 20 | let controller = PersistanceController(inMemory: true) 21 | 22 | for i in 0..<10 { 23 | let draft = Draft(context: controller.container.viewContext) 24 | draft.text = "Test draft \(i)" 25 | draft.date = Date() 26 | draft.id = UUID() 27 | } 28 | 29 | return controller 30 | } 31 | 32 | init(inMemory: Bool = false) { 33 | container = NSPersistentContainer(name: "DraftsModel") 34 | 35 | if inMemory { 36 | container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null") 37 | } 38 | 39 | container.loadPersistentStores { description, error in 40 | if let error = error { 41 | fatalError("Error: \(error.localizedDescription)") 42 | } 43 | } 44 | } 45 | 46 | func save() { 47 | let context = container.viewContext 48 | 49 | if context.hasChanges { 50 | do { 51 | try context.save() 52 | } catch { 53 | print(error.localizedDescription) 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Broadcast/Helper Views/RemoteImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteImage.swift 3 | // Broadcast 4 | // 5 | // Created by Daniel Eden on 28/06/2021. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | import UIKit 11 | 12 | class ImageLoader: ObservableObject { 13 | @Published var image: UIImage? 14 | private let url: URL 15 | 16 | init(url: URL) { 17 | self.url = url 18 | } 19 | 20 | deinit { 21 | cancel() 22 | } 23 | 24 | private var cancellable: AnyCancellable? 25 | 26 | func load() { 27 | cancellable = URLSession.shared.dataTaskPublisher(for: url) 28 | .map { UIImage(data: $0.data) } 29 | .replaceError(with: nil) 30 | .receive(on: DispatchQueue.main) 31 | .sink { [weak self] image in 32 | withAnimation { self?.image = image } 33 | } 34 | } 35 | 36 | func cancel() { 37 | cancellable?.cancel() 38 | } 39 | } 40 | 41 | struct RemoteImage: View { 42 | @StateObject private var loader: ImageLoader 43 | private let placeholder: Placeholder 44 | 45 | init(url: URL, @ViewBuilder placeholder: () -> Placeholder) { 46 | self.placeholder = placeholder() 47 | _loader = StateObject(wrappedValue: ImageLoader(url: url)) 48 | } 49 | 50 | var body: some View { 51 | content 52 | .onAppear(perform: loader.load) 53 | } 54 | 55 | private var content: some View { 56 | Group { 57 | if loader.image != nil { 58 | Image(uiImage: loader.image!) 59 | .resizable() 60 | } else { 61 | placeholder 62 | } 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Broadcast/Helpers/ThemeHelper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThemeHelper.swift 3 | // Broadcast 4 | // 5 | // Created by Daniel Eden on 27/06/2021. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | import SwiftUI 11 | 12 | extension Color: RawRepresentable { 13 | public init?(rawValue: String) { 14 | guard let data = Data(base64Encoded: rawValue) else{ 15 | self = .black 16 | return 17 | } 18 | 19 | do { 20 | let color = try NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as? UIColor ?? .black 21 | self = Color(color) 22 | }catch{ 23 | self = .accentColor 24 | } 25 | } 26 | 27 | public var rawValue: String { 28 | do { 29 | let data = try NSKeyedArchiver.archivedData(withRootObject: UIColor(self), requiringSecureCoding: false) as Data 30 | return data.base64EncodedString() 31 | } catch { 32 | return "" 33 | } 34 | } 35 | } 36 | 37 | class ThemeHelper: ObservableObject { 38 | static let shared = ThemeHelper() 39 | 40 | @AppStorage("themeColor") var color = Color("twitterBrandColor") 41 | 42 | @AppStorage("themeColorIndex") private var currentColorIndex = 0 { 43 | didSet { 44 | color = allColors[currentColorIndex] 45 | } 46 | } 47 | 48 | private var allColors: [Color] = [ 49 | Color("twitterBrandColor"), 50 | Color(.systemIndigo), 51 | Color(.systemPurple), 52 | Color(.systemPink), 53 | Color(.systemOrange), 54 | Color(.systemGreen), 55 | Color(.systemTeal), 56 | ] 57 | 58 | func rotateTheme() { 59 | currentColorIndex = (currentColorIndex + 1) % allColors.count 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Broadcast.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swifter", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/daneden/Swifter", 7 | "state" : { 8 | "revision" : "21a1cf736971d07dec56cf5cc0294f31a34ec528", 9 | "version" : "2.5.1" 10 | } 11 | }, 12 | { 13 | "identity" : "swiftkeychainwrapper", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/jrendel/SwiftKeychainWrapper", 16 | "state" : { 17 | "revision" : "185a3165346a03767101c4f62e9a545a0fe0530f", 18 | "version" : "4.0.1" 19 | } 20 | }, 21 | { 22 | "identity" : "swiftui-introspect", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/siteline/SwiftUI-Introspect", 25 | "state" : { 26 | "revision" : "2e09be8af614401bc9f87d40093ec19ce56ccaf2", 27 | "version" : "0.1.3" 28 | } 29 | }, 30 | { 31 | "identity" : "twitter-text", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/nysander/twitter-text.git", 34 | "state" : { 35 | "revision" : "09fb2896ca4bec3b17513de2da9376559062116d", 36 | "version" : "0.0.1" 37 | } 38 | }, 39 | { 40 | "identity" : "unicodeurl", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/nysander/UnicodeURL.git", 43 | "state" : { 44 | "revision" : "fe15c8b585c2e6d22dfe81da899183e6aa376a93", 45 | "version" : "0.1.0" 46 | } 47 | } 48 | ], 49 | "version" : 2 50 | } 51 | -------------------------------------------------------------------------------- /Broadcast/Extensions/NSRegularExpression+Convenience.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSRegularExpression+Convenience.swift 3 | // Broadcast 4 | // 5 | // Created by Daniel Eden on 03/08/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | extension NSRegularExpression { 11 | convenience init(_ pattern: String) { 12 | do { 13 | try self.init(pattern: pattern) 14 | } catch { 15 | preconditionFailure("Illegal regular expression: \(pattern).") 16 | } 17 | } 18 | 19 | convenience init(_ pattern: String, options: NSRegularExpression.Options) { 20 | do { 21 | try self.init(pattern: pattern, options: options) 22 | } catch { 23 | preconditionFailure("Illegal regular expression: \(pattern).") 24 | } 25 | } 26 | } 27 | 28 | extension NSRegularExpression { 29 | func matches(_ string: String) -> Bool { 30 | let range = NSRange(location: 0, length: string.utf16.count) 31 | return firstMatch(in: string, options: [], range: range) != nil 32 | } 33 | 34 | func firstMatchAsString(_ string: String) -> String? { 35 | let nsString = string as NSString 36 | let range = NSRange(location: 0, length: string.utf16.count) 37 | guard let firstMatch = firstMatch(in: string, options: [], range: range) else { 38 | return nil 39 | } 40 | 41 | return nsString.substring(with: firstMatch.range) 42 | } 43 | } 44 | 45 | extension String { 46 | static func ~= (lhs: String, rhs: String) -> Bool { 47 | guard let regex = try? NSRegularExpression(pattern: rhs) else { return false } 48 | let range = NSRange(location: 0, length: lhs.utf16.count) 49 | return regex.firstMatch(in: lhs, options: [], range: range) != nil 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Broadcast/Helper Views/SafariView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SafariView.swift 3 | // Broadcast 4 | // 5 | // Created by Daniel Eden on 26/06/2021. 6 | // 7 | 8 | import SwiftUI 9 | import SafariServices 10 | 11 | struct SafariView: UIViewControllerRepresentable { 12 | class SafariViewControllerWrapper: UIViewController { 13 | private var safariViewController: SFSafariViewController? 14 | 15 | var url: URL? { 16 | didSet { 17 | if let safariViewController = safariViewController { 18 | safariViewController.willMove(toParent: self) 19 | safariViewController.view.removeFromSuperview() 20 | safariViewController.removeFromParent() 21 | self.safariViewController = nil 22 | } 23 | 24 | guard let url = url else { return } 25 | 26 | let newSafariViewController = SFSafariViewController(url: url) 27 | addChild(newSafariViewController) 28 | newSafariViewController.view.frame = view.frame 29 | view.addSubview(newSafariViewController.view) 30 | newSafariViewController.didMove(toParent: self) 31 | self.safariViewController = newSafariViewController 32 | } 33 | } 34 | 35 | override func viewDidLoad() { 36 | super.viewDidLoad() 37 | self.url = nil 38 | } 39 | } 40 | 41 | typealias UIViewControllerType = SafariViewControllerWrapper 42 | 43 | @Binding var url: URL? 44 | 45 | func makeUIViewController(context: UIViewControllerRepresentableContext) -> SafariViewControllerWrapper { 46 | return SafariViewControllerWrapper() 47 | } 48 | 49 | func updateUIViewController(_ safariViewControllerWrapper: SafariViewControllerWrapper, 50 | context: UIViewControllerRepresentableContext) { 51 | safariViewControllerWrapper.url = url 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Broadcast/Helper Views/TweetView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TweetView.swift 3 | // Broadcast 4 | // 5 | // Created by Daniel Eden on 01/08/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct TweetView: View { 11 | @ScaledMetric private var avatarSize: CGFloat = 36 12 | @ScaledMetric private var padding: CGFloat = 4 13 | var tweet: TwitterClient.Tweet 14 | 15 | var formatter: RelativeDateTimeFormatter { 16 | let formatter = RelativeDateTimeFormatter() 17 | formatter.dateTimeStyle = .named 18 | formatter.unitsStyle = .short 19 | formatter.formattingContext = .standalone 20 | 21 | return formatter 22 | } 23 | 24 | var body: some View { 25 | HStack(alignment: .top) { 26 | if let imageUrl = tweet.author?.profileImageURL { 27 | RemoteImage(url: imageUrl, placeholder: { ProgressView() }) 28 | .aspectRatio(contentMode: .fill) 29 | .frame(width: avatarSize, height: avatarSize) 30 | .cornerRadius(36) 31 | } 32 | 33 | VStack(alignment: .leading, spacing: 4) { 34 | HStack { 35 | if let tweetAuthorName = tweet.author?.name, 36 | let screenName = tweet.author?.screenName, 37 | let date = tweet.date { 38 | Text("\(Text(tweetAuthorName).fontWeight(.bold).foregroundColor(.primary)) \(Text("@\(screenName)")) • \(Text(formatter.localizedString(for: date, relativeTo: Date())))") 39 | .foregroundColor(.secondary) 40 | } 41 | } 42 | .lineLimit(1) 43 | 44 | if let tweetText = tweet.text { 45 | Text(tweetText).lineSpacing(0) 46 | } 47 | } 48 | } 49 | .font(.broadcastFootnote) 50 | .padding(.vertical, padding) 51 | } 52 | } 53 | 54 | struct TweetView_Previews: PreviewProvider { 55 | static var previews: some View { 56 | TweetView(tweet: TwitterClient.Tweet.mockTweet) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Broadcast/Helpers/Haptics.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Haptics.swift 3 | // Broadcast 4 | // 5 | // Created by Daniel Eden on 29/06/2021. 6 | // 7 | 8 | import Foundation 9 | import CoreHaptics 10 | import UIKit 11 | 12 | class Haptics { 13 | private var engine: CHHapticEngine? 14 | static let shared = Haptics() 15 | 16 | static var isSupported: Bool { 17 | CHHapticEngine.capabilitiesForHardware().supportsHaptics 18 | } 19 | 20 | init() { 21 | prepareHaptics() 22 | } 23 | 24 | deinit { 25 | engine?.stop() 26 | } 27 | 28 | func sendFeedback( 29 | intensity: Float, 30 | sharpness: Float 31 | ) { 32 | try? engine?.start() 33 | 34 | guard Haptics.isSupported else { return } 35 | var events = [CHHapticEvent]() 36 | 37 | let intensity = CHHapticEventParameter(parameterID: .hapticIntensity, value: intensity) 38 | let sharpness = CHHapticEventParameter(parameterID: .hapticSharpness, value: sharpness) 39 | let event = CHHapticEvent(eventType: .hapticTransient, parameters: [intensity, sharpness], relativeTime: 0) 40 | events.append(event) 41 | 42 | do { 43 | let pattern = try CHHapticPattern(events: events, parameters: []) 44 | let player = try engine?.makePlayer(with: pattern) 45 | try player?.start(atTime: 0) 46 | } catch { 47 | print("Failed to play pattern: \(error.localizedDescription).") 48 | } 49 | } 50 | 51 | func sendStandardFeedback(feedbackType: UINotificationFeedbackGenerator.FeedbackType) { 52 | let generator = UINotificationFeedbackGenerator() 53 | generator.notificationOccurred(feedbackType) 54 | } 55 | 56 | func prepareHaptics() { 57 | guard Haptics.isSupported else { return } 58 | 59 | do { 60 | self.engine = try CHHapticEngine() 61 | 62 | try engine?.start() 63 | } catch { 64 | print("There was an error creating the engine: \(error.localizedDescription)") 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Broadcast/WelcomeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WelcomeView.swift 3 | // Broadcast 4 | // 5 | // Created by Daniel Eden on 27/06/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct WelcomeView: View { 11 | @EnvironmentObject var themeHelper: ThemeHelper 12 | @ScaledMetric var spacing: CGFloat = 24 13 | 14 | @State var rotation: Double = -3 15 | 16 | var animation = Animation.interactiveSpring(response: 0.2, dampingFraction: 0.2, blendDuration: 0.4) 17 | 18 | var body: some View { 19 | VStack(alignment: .leading, spacing: spacing) { 20 | Spacer() 21 | 22 | HStack { 23 | Spacer() 24 | Label("Broadcast", systemImage: "exclamationmark.bubble.fill") 25 | .font(.broadcastLargeTitle.weight(.heavy)) 26 | .foregroundColor(.white) 27 | .rotationEffect(Angle(degrees: rotation)) 28 | .padding() 29 | .background( 30 | RoundedRectangle(cornerRadius: spacing, style: .continuous) 31 | .rotation(Angle(degrees: rotation / 2)) 32 | .fill(Color.accentColor) 33 | ) 34 | .onTapGesture { 35 | themeHelper.rotateTheme() 36 | Haptics.shared.sendStandardFeedback(feedbackType: .success) 37 | 38 | withAnimation(animation) { 39 | rotation = Double.random(in: -7...7) 40 | } 41 | } 42 | Spacer() 43 | } 44 | 45 | Spacer() 46 | 47 | Group { 48 | Text("Broadcast is a Twitter app like no other: it’s write-only.") 49 | .rotationEffect(Angle(degrees: rotation * 0.125)) 50 | Text("No notifications, likes, retweets, ads, replies, sliding-into-DMs, lists—not even a timeline.") 51 | .rotationEffect(Angle(degrees: rotation * -0.2)) 52 | Text("Tweet like nobody’s watching.") 53 | .rotationEffect(Angle(degrees: rotation * 0.3)) 54 | }.font(.broadcastTitle.bold()) 55 | 56 | Spacer() 57 | } 58 | } 59 | } 60 | 61 | struct WelcomeView_Previews: PreviewProvider { 62 | static var previews: some View { 63 | WelcomeView() 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Broadcast/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | $(MARKETING_VERSION) 19 | CFBundleURLTypes 20 | 21 | 22 | CFBundleTypeRole 23 | Editor 24 | CFBundleURLSchemes 25 | 26 | twitter-broadcast 27 | 28 | 29 | 30 | CFBundleVersion 31 | $(CURRENT_PROJECT_VERSION) 32 | ITSAppUsesNonExemptEncryption 33 | 34 | LSRequiresIPhoneOS 35 | 36 | NSPhotoLibraryUsageDescription 37 | Allow permission to enable tweeting photos 38 | UIApplicationSceneManifest 39 | 40 | UIApplicationSupportsMultipleScenes 41 | 42 | 43 | UIApplicationSupportsIndirectInputEvents 44 | 45 | UILaunchScreen 46 | 47 | UIRequiredDeviceCapabilities 48 | 49 | armv7 50 | 51 | UISupportedInterfaceOrientations 52 | 53 | UIInterfaceOrientationPortrait 54 | UIInterfaceOrientationLandscapeLeft 55 | UIInterfaceOrientationLandscapeRight 56 | 57 | UISupportedInterfaceOrientations~ipad 58 | 59 | UIInterfaceOrientationPortrait 60 | UIInterfaceOrientationPortraitUpsideDown 61 | UIInterfaceOrientationLandscapeLeft 62 | UIInterfaceOrientationLandscapeRight 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /Broadcast/Helper Views/BroadcastButtonStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BroadcastButtonStyle.swift 3 | // Broadcast 4 | // 5 | // Created by Daniel Eden on 26/06/2021. 6 | // 7 | 8 | import SwiftUI 9 | import CoreHaptics 10 | 11 | struct BroadcastLabelStyle: LabelStyle { 12 | enum Appearance { 13 | case iconOnly, normal 14 | } 15 | 16 | var appearance: Appearance = .normal 17 | var accessibilityLabel: String 18 | 19 | func makeBody(configuration: Configuration) -> some View { 20 | HStack(alignment: .firstTextBaseline) { 21 | configuration.icon 22 | if appearance == .normal { 23 | configuration.title 24 | .accessibility(hidden: true) 25 | } 26 | }.accessibilityLabel(accessibilityLabel) 27 | } 28 | } 29 | 30 | struct BroadcastButtonStyle: ButtonStyle { 31 | enum Prominence { 32 | case primary, secondary, tertiary, destructive 33 | } 34 | 35 | @ScaledMetric var paddingSize: CGFloat = 16 36 | var prominence: Prominence = .primary 37 | var isFullWidth = true 38 | var isLoading = false 39 | 40 | var background: some View { 41 | Group { 42 | switch prominence { 43 | case .primary: 44 | Color.accentColor 45 | case .secondary: 46 | Color.accentColor.opacity(0.1) 47 | case .tertiary: 48 | VisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterial)) 49 | case .destructive: 50 | Color(.systemRed) 51 | } 52 | } 53 | } 54 | 55 | var foregroundColor: Color { 56 | switch prominence { 57 | case .secondary: 58 | return .accentColor 59 | case .tertiary: 60 | return .primary 61 | default: 62 | return .white 63 | } 64 | } 65 | 66 | func makeBody(configuration: Configuration) -> some View { 67 | HStack { 68 | if isFullWidth { Spacer(minLength: 0) } 69 | 70 | configuration.label 71 | .font(.broadcastBody.bold()) 72 | .opacity(configuration.isPressed ? 0.8 : 1) 73 | .opacity(isLoading ? 0 : 1) 74 | 75 | if isFullWidth { Spacer(minLength: 0) } 76 | } 77 | .padding(paddingSize) 78 | .background(background.padding(-paddingSize)) 79 | .foregroundColor(foregroundColor) 80 | .overlay( 81 | Group { 82 | if isLoading { 83 | ProgressView() 84 | } 85 | } 86 | ) 87 | .clipShape(Capsule()) 88 | .scaleEffect(configuration.isPressed ? 0.95 : 1) 89 | .animation(.interactiveSpring(), value: configuration.isPressed) 90 | .onChange(of: configuration.isPressed) { isPressed in 91 | Haptics.shared.sendFeedback( 92 | intensity: isPressed ? 0.8 : 0.4, 93 | sharpness: isPressed ? 1 : 0.7 94 | ) 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /Broadcast/Helper Views/DraftsListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DraftsListView.swift 3 | // Broadcast 4 | // 5 | // Created by Daniel Eden on 03/07/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct DraftsListView: View { 11 | @ScaledMetric var thumbnailSize: CGFloat = 56 12 | @Environment(\.presentationMode) var presentationMode 13 | @Environment(\.managedObjectContext) var managedObjectContext 14 | 15 | @FetchRequest(entity: Draft.entity(), sortDescriptors: [NSSortDescriptor(keyPath: \Draft.date, ascending: true)]) 16 | var drafts: FetchedResults 17 | 18 | @EnvironmentObject var twitterClient: TwitterClient 19 | @EnvironmentObject var themeHelper: ThemeHelper 20 | 21 | var body: some View { 22 | NavigationView { 23 | Group { 24 | if drafts.isEmpty { 25 | NullStateView(type: .drafts) 26 | } else { 27 | List { 28 | ForEach(drafts) { draft in 29 | HStack { 30 | VStack(alignment: .leading) { 31 | if let date = draft.date { 32 | Text(date, style: .date) 33 | .font(.broadcastFootnote) 34 | .foregroundColor(.secondary) 35 | } 36 | 37 | if let text = draft.text { 38 | Text(text) 39 | } else { 40 | Text("Empty Draft").foregroundColor(.secondary) 41 | } 42 | } 43 | 44 | Spacer() 45 | 46 | if let imageData = draft.media, 47 | let image = UIImage(data: imageData) { 48 | Image(uiImage: image) 49 | .resizable() 50 | .aspectRatio(contentMode: .fill) 51 | .frame(width: thumbnailSize, height: thumbnailSize) 52 | .cornerRadius(8) 53 | } 54 | } 55 | .contentShape(Rectangle()) 56 | .onTapGesture { 57 | presentationMode.wrappedValue.dismiss() 58 | twitterClient.retreiveDraft(draft: draft) 59 | } 60 | }.onDelete(perform: deleteDrafts) 61 | } 62 | } 63 | } 64 | .toolbar { 65 | EditButton() 66 | 67 | Button(action: { presentationMode.wrappedValue.dismiss() }) { 68 | Text("Close") 69 | } 70 | } 71 | .font(.broadcastBody) 72 | .navigationTitle("Drafts") 73 | }.accentColor(themeHelper.color) 74 | } 75 | 76 | func deleteDrafts(at offsets: IndexSet) { 77 | for index in offsets { 78 | let draft = drafts[index] 79 | managedObjectContext.delete(draft) 80 | } 81 | 82 | PersistanceController.shared.save() 83 | } 84 | } 85 | 86 | struct DraftsListView_Previews: PreviewProvider { 87 | static var previews: some View { 88 | DraftsListView() 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Broadcast/Helper Views/ActionBarView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ActionBarView.swift 3 | // Broadcast 4 | // 5 | // Created by Daniel Eden on 03/07/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ActionBarView: View { 11 | @ScaledMetric var barHeight: CGFloat = 80 12 | @EnvironmentObject var twitterClient: TwitterClient 13 | @Binding var replying: Bool 14 | 15 | @State private var photoPickerIsPresented = false 16 | 17 | var body: some View { 18 | publishingActions 19 | .disabled(twitterClient.state == .busy) 20 | .sheet(isPresented: $photoPickerIsPresented) { 21 | ImagePicker(chosenImage: $twitterClient.draft.media) 22 | } 23 | } 24 | 25 | var publishingActions: some View { 26 | HStack { 27 | if let replyId = twitterClient.lastTweet?.id { 28 | Button(action: { 29 | if replying { 30 | twitterClient.sendReply(to: replyId) 31 | } else { 32 | withAnimation(.springAnimation) { replying = true } 33 | } 34 | }) { 35 | Label("Send Reply", systemImage: "arrowshape.turn.up.left.fill") 36 | .font(.broadcastHeadline) 37 | .labelStyle( 38 | BroadcastLabelStyle( 39 | appearance: !replying ? .iconOnly : .normal, 40 | accessibilityLabel: "Send Reply" 41 | ) 42 | ) 43 | } 44 | .buttonStyle( 45 | BroadcastButtonStyle( 46 | prominence: replying ? .primary : .secondary, 47 | isFullWidth: replying, 48 | isLoading: twitterClient.state == .busy && replying 49 | ) 50 | ) 51 | .disabled(replying && !twitterClient.draft.isValid) 52 | } 53 | 54 | Button(action: { 55 | if !replying { 56 | twitterClient.sendTweet() 57 | } else { 58 | withAnimation(.springAnimation) { replying = false } 59 | } 60 | }) { 61 | Label("Send Tweet", systemImage: "paperplane.fill") 62 | .font(.broadcastHeadline) 63 | .labelStyle( 64 | BroadcastLabelStyle( 65 | appearance: replying ? .iconOnly : .normal, 66 | accessibilityLabel: "Send Tweet" 67 | ) 68 | ) 69 | } 70 | .buttonStyle( 71 | BroadcastButtonStyle( 72 | prominence: !replying ? .primary : .secondary, 73 | isFullWidth: !replying, 74 | isLoading: twitterClient.state == .busy && !replying 75 | ) 76 | ) 77 | .disabled(!replying && !twitterClient.draft.isValid) 78 | .accessibilityIdentifier("sendTweetButton") 79 | 80 | Button(action: { 81 | photoPickerIsPresented.toggle() 82 | UIApplication.shared.endEditing() 83 | }) { 84 | Label("Add Media", systemImage: "photo.on.rectangle.angled") 85 | .labelStyle(IconOnlyLabelStyle()) 86 | } 87 | .buttonStyle(BroadcastButtonStyle(prominence: .tertiary, isFullWidth: false)) 88 | .accessibilityIdentifier("imagePickerButton") 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Broadcast/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "app-icon-20@2x.png", 5 | "idiom" : "iphone", 6 | "scale" : "2x", 7 | "size" : "20x20" 8 | }, 9 | { 10 | "filename" : "app-icon-20@3x.png", 11 | "idiom" : "iphone", 12 | "scale" : "3x", 13 | "size" : "20x20" 14 | }, 15 | { 16 | "filename" : "app-icon-29@1x.png", 17 | "idiom" : "iphone", 18 | "scale" : "1x", 19 | "size" : "29x29" 20 | }, 21 | { 22 | "filename" : "app-icon-29@2x.png", 23 | "idiom" : "iphone", 24 | "scale" : "2x", 25 | "size" : "29x29" 26 | }, 27 | { 28 | "filename" : "app-icon-29@3x.png", 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "29x29" 32 | }, 33 | { 34 | "filename" : "app-icon-40@2x.png", 35 | "idiom" : "iphone", 36 | "scale" : "2x", 37 | "size" : "40x40" 38 | }, 39 | { 40 | "filename" : "app-icon-40@3x.png", 41 | "idiom" : "iphone", 42 | "scale" : "3x", 43 | "size" : "40x40" 44 | }, 45 | { 46 | "filename" : "app-icon-60@2x.png", 47 | "idiom" : "iphone", 48 | "scale" : "2x", 49 | "size" : "60x60" 50 | }, 51 | { 52 | "filename" : "app-icon-60@3x.png", 53 | "idiom" : "iphone", 54 | "scale" : "3x", 55 | "size" : "60x60" 56 | }, 57 | { 58 | "filename" : "app-icon-20@1x.png", 59 | "idiom" : "ipad", 60 | "scale" : "1x", 61 | "size" : "20x20" 62 | }, 63 | { 64 | "filename" : "app-icon-20@2x-1.png", 65 | "idiom" : "ipad", 66 | "scale" : "2x", 67 | "size" : "20x20" 68 | }, 69 | { 70 | "filename" : "app-icon-29@1x-1.png", 71 | "idiom" : "ipad", 72 | "scale" : "1x", 73 | "size" : "29x29" 74 | }, 75 | { 76 | "filename" : "app-icon-29@2x-1.png", 77 | "idiom" : "ipad", 78 | "scale" : "2x", 79 | "size" : "29x29" 80 | }, 81 | { 82 | "filename" : "app-icon-40@1x.png", 83 | "idiom" : "ipad", 84 | "scale" : "1x", 85 | "size" : "40x40" 86 | }, 87 | { 88 | "filename" : "app-icon-40@2x-1.png", 89 | "idiom" : "ipad", 90 | "scale" : "2x", 91 | "size" : "40x40" 92 | }, 93 | { 94 | "filename" : "app-icon-76@1x.png", 95 | "idiom" : "ipad", 96 | "scale" : "1x", 97 | "size" : "76x76" 98 | }, 99 | { 100 | "filename" : "app-icon-76@2x.png", 101 | "idiom" : "ipad", 102 | "scale" : "2x", 103 | "size" : "76x76" 104 | }, 105 | { 106 | "filename" : "app-icon-83.5@2x.png", 107 | "idiom" : "ipad", 108 | "scale" : "2x", 109 | "size" : "83.5x83.5" 110 | }, 111 | { 112 | "filename" : "app-icon-1024@1x.png", 113 | "idiom" : "ios-marketing", 114 | "scale" : "1x", 115 | "size" : "1024x1024" 116 | } 117 | ], 118 | "info" : { 119 | "author" : "xcode", 120 | "version" : 1 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /BroadcastUITests/BroadcastUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BroadcastUITests.swift 3 | // BroadcastUITests 4 | // 5 | // Created by Daniel Eden on 15/08/2021. 6 | // 7 | 8 | import XCTest 9 | 10 | extension XCUIApplication { 11 | static func initWithLaunchParameters() -> XCUIApplication { 12 | let instance = XCUIApplication() 13 | instance.launchArguments = ["isTestEnvironment"] 14 | 15 | return instance 16 | } 17 | } 18 | 19 | class BroadcastUITests: XCTestCase { 20 | override func setUpWithError() throws { 21 | continueAfterFailure = false 22 | } 23 | 24 | func testKeyElementsExist() throws { 25 | let app = XCUIApplication.initWithLaunchParameters() 26 | app.launch() 27 | 28 | let sendTweet = app.buttons["sendTweetButton"] 29 | XCTAssert(sendTweet.exists) 30 | XCTAssert(!sendTweet.isEnabled) 31 | 32 | let composer = app.textViews["tweetComposer"] 33 | XCTAssert(composer.exists) 34 | } 35 | 36 | func testSendTweetButtonEnabledOnValidText() throws { 37 | let app = XCUIApplication.initWithLaunchParameters() 38 | app.launch() 39 | 40 | let sendTweet = app.buttons["sendTweetButton"] 41 | let composer = app.textViews["tweetComposer"] 42 | 43 | composer.tap() 44 | UIPasteboard.general.string = "Hello, world" 45 | composer.press(forDuration: 1.1) 46 | app.menuItems["Paste"].tap() 47 | _ = XCTWaiter.wait(for: [expectation(description: "Wait for .5 seconds")], timeout: 0.5) 48 | 49 | XCTAssertEqual(composer.value as? String, "Hello, world") 50 | XCTAssert(sendTweet.isEnabled) 51 | } 52 | 53 | func testSendTweetButtonDisabledOnEmptyText() throws { 54 | let app = XCUIApplication.initWithLaunchParameters() 55 | app.launch() 56 | 57 | let sendTweet = app.buttons["sendTweetButton"] 58 | let composer = app.textViews["tweetComposer"] 59 | 60 | composer.tap() 61 | UIPasteboard.general.string = " " 62 | composer.press(forDuration: 1.1) 63 | app.menuItems["Paste"].tap() 64 | _ = XCTWaiter.wait(for: [expectation(description: "Wait for .5 seconds")], timeout: 0.5) 65 | 66 | XCTAssertEqual(composer.value as? String, " ") 67 | XCTAssert(!sendTweet.isEnabled) 68 | } 69 | 70 | func testSendTweetButtonDisabledOnLargeText() throws { 71 | let app = XCUIApplication.initWithLaunchParameters() 72 | app.launch() 73 | 74 | let sendTweet = app.buttons["sendTweetButton"] 75 | let composer = app.textViews["tweetComposer"] 76 | 77 | composer.tap() 78 | UIPasteboard.general.string = String(repeating: "a", count: 281) 79 | composer.press(forDuration: 1.1) 80 | app.menuItems["Paste"].tap() 81 | _ = XCTWaiter.wait(for: [expectation(description: "Wait for .5 seconds")], timeout: 0.5) 82 | 83 | XCTAssert(!sendTweet.isEnabled) 84 | } 85 | 86 | func testLogout() throws { 87 | let app = XCUIApplication.initWithLaunchParameters() 88 | app.launch() 89 | 90 | let profilePhoto = app.images["profilePhotoButton"] 91 | profilePhoto.tap() 92 | 93 | let logoutHandle = app.descendants(matching: .any).matching(identifier: "logoutProfilePhotoHandle").firstMatch 94 | let logoutTarget = app.images["logoutTarget"] 95 | logoutHandle.press(forDuration: 0.1, thenDragTo: logoutTarget) 96 | 97 | _ = XCTWaiter.wait(for: [expectation(description: "Wait for .5 seconds")], timeout: 0.5) 98 | 99 | let loginButton = app.buttons["loginButton"] 100 | XCTAssert(loginButton.exists) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Broadcast.xcodeproj/xcshareddata/xcschemes/Broadcast.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 34 | 35 | 36 | 37 | 39 | 45 | 46 | 47 | 48 | 49 | 59 | 61 | 67 | 68 | 69 | 70 | 76 | 78 | 84 | 85 | 86 | 87 | 89 | 90 | 93 | 94 | 95 | -------------------------------------------------------------------------------- /Broadcast/SignOutView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SignOutView.swift 3 | // Broadcast 4 | // 5 | // Created by Daniel Eden on 27/06/2021. 6 | // 7 | 8 | import SwiftUI 9 | import CoreHaptics 10 | 11 | struct SignOutView: View { 12 | @Environment(\.colorScheme) var colorScheme 13 | @Environment(\.presentationMode) var presentationMode 14 | @EnvironmentObject var twitterClient: TwitterClient 15 | @EnvironmentObject var themeHelper: ThemeHelper 16 | 17 | @State private var offset = CGSize.zero 18 | @State private var willDelete = false 19 | 20 | @ScaledMetric var size: CGFloat = 88 21 | 22 | var labelOpacity: Double { 23 | Double(1 - abs(offset.height) / 200) 24 | } 25 | 26 | @State private var animating = false 27 | 28 | var body: some View { 29 | VStack { 30 | Spacer() 31 | if let screenName = twitterClient.user?.screenName { 32 | Label("Drag to sign out @\(screenName)", systemImage: "arrow.down.circle") 33 | .font(.broadcastBody.bold()) 34 | .foregroundColor(.secondary) 35 | .padding() 36 | .opacity(labelOpacity) 37 | } 38 | 39 | VStack { 40 | Group { 41 | if let profileImageURL = twitterClient.user?.profileImageURL { 42 | RemoteImage(url: profileImageURL, placeholder: { ProgressView() }) 43 | .aspectRatio(contentMode: .fill) 44 | .clipShape(Circle()) 45 | } else { 46 | Image(systemName: "person.crop.circle.fill") 47 | .resizable() 48 | } 49 | } 50 | .shadow( 51 | color: (willDelete || colorScheme == .dark) ? .black.opacity(0.2) : .accentColor, 52 | radius: 8, x: 0, y: 4 53 | ) 54 | .foregroundColor(.white) 55 | .padding(8) 56 | .frame(width: size, height: size) 57 | .background(willDelete 58 | ? Color(.secondarySystemBackground) 59 | : .accentColor.opacity(colorScheme == .dark ? 0.9 : 0.5) 60 | ) 61 | .clipShape(Circle()) 62 | .onTapGesture { 63 | themeHelper.rotateTheme() 64 | Haptics.shared.sendStandardFeedback(feedbackType: .success) 65 | } 66 | .offset(offset) 67 | .highPriorityGesture( 68 | DragGesture() 69 | .onChanged { gesture in 70 | withAnimation { self.offset.height = min(gesture.translation.height, 200 + size) } 71 | 72 | withAnimation(.interactiveSpring()) { willDelete = self.offset.height >= 200 } 73 | } 74 | 75 | .onEnded { _ in 76 | if self.offset.height >= 200 { 77 | startSignOut() 78 | } else { 79 | withAnimation(.interactiveSpring(response: 0.3, dampingFraction: 0.6, blendDuration: 0.4)) { 80 | self.offset = .zero 81 | willDelete = false 82 | } 83 | } 84 | } 85 | ) 86 | .accessibilityIdentifier("logoutProfilePhotoHandle") 87 | 88 | Color.clear.frame(height: 180) 89 | 90 | Image(systemName: "trash") 91 | .resizable() 92 | .aspectRatio(contentMode: .fit) 93 | .padding(size * 0.3) 94 | .frame(width: size, height: size) 95 | .background(willDelete ? Color(.systemRed) : Color(.secondarySystemBackground)) 96 | .foregroundColor(willDelete ? .white : .primary) 97 | .clipShape(Circle()) 98 | .accessibilityIdentifier("logoutTarget") 99 | } 100 | Spacer() 101 | 102 | Button(action: { presentationMode.wrappedValue.dismiss() }) { 103 | Text("Close") 104 | }.buttonStyle(BroadcastButtonStyle(prominence: .tertiary)) 105 | .opacity(labelOpacity) 106 | } 107 | .padding() 108 | .onChange(of: willDelete) { willDelete in 109 | let v: Float = willDelete ? 1 : 0.3 110 | Haptics.shared.sendFeedback(intensity: v, sharpness: v) 111 | } 112 | .accentColor(themeHelper.color) 113 | } 114 | 115 | func startSignOut() { 116 | twitterClient.signOut() 117 | presentationMode.wrappedValue.dismiss() 118 | } 119 | } 120 | 121 | struct SignOutView_Previews: PreviewProvider { 122 | static var previews: some View { 123 | SignOutView() 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /Broadcast/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // Broadcast 4 | // 5 | // Created by Daniel Eden on 26/06/2021. 6 | // 7 | 8 | import SwiftUI 9 | import Introspect 10 | import TwitterText 11 | 12 | struct ContentView: View { 13 | @ScaledMetric private var captionSize: CGFloat = 14 14 | @ScaledMetric private var bottomPadding: CGFloat = 80 15 | @ScaledMetric private var replyBoxLimit: CGFloat = 96 16 | 17 | @EnvironmentObject var twitterClient: TwitterClient 18 | 19 | @State private var photoPickerIsPresented = false 20 | @State private var signOutScreenIsPresented = false 21 | @State private var repliesSheetIsPresented = false 22 | 23 | @State private var sendingTweet = false 24 | @State private var replying = false 25 | @State private var replyBoxHeight: CGFloat = 0 26 | 27 | private var imageHeightCompensation: CGFloat { 28 | (twitterClient.draft.media == nil ? 0 : bottomPadding) + 29 | (replying ? min(replyBoxHeight, replyBoxLimit) : 0) 30 | } 31 | 32 | var body: some View { 33 | GeometryReader { geom in 34 | ZStack(alignment: .bottom) { 35 | ScrollView { 36 | VStack { 37 | if replying, let lastTweet = twitterClient.lastTweet { 38 | LastTweetReplyView(lastTweet: lastTweet) 39 | .background(GeometryReader { geometry in 40 | Color.clear.preference( 41 | key: ReplyBoxSizePreferenceKey.self, 42 | value: geometry.size.height 43 | ) 44 | }) 45 | .onTapGesture { 46 | repliesSheetIsPresented = true 47 | } 48 | } 49 | 50 | if case .error(let errorMessage) = twitterClient.state { 51 | Text(errorMessage ?? "Some weird kind of error occurred; @_dte is probably to blame since he made this app.") 52 | .font(.broadcastFootnote.weight(.semibold)) 53 | .foregroundColor(Color(.systemRed)) 54 | .padding() 55 | .frame(maxWidth: .infinity) 56 | .background(Color(.systemRed).opacity(0.2)) 57 | .cornerRadius(captionSize) 58 | .onTapGesture { 59 | withAnimation { 60 | twitterClient.state = .idle 61 | } 62 | } 63 | } 64 | 65 | if $twitterClient.user.wrappedValue != nil { 66 | ComposerView(signOutScreenIsPresented: $signOutScreenIsPresented) 67 | .frame( 68 | height: geom.size.height - (bottomPadding + (captionSize * 2)) - imageHeightCompensation, 69 | alignment: .topLeading 70 | ) 71 | 72 | AttachmentThumbnail(image: $twitterClient.draft.media) 73 | } else { 74 | WelcomeView() 75 | } 76 | } 77 | .padding() 78 | .padding(.bottom, bottomPadding) 79 | .frame(maxWidth: geom.size.width) 80 | } 81 | 82 | VStack { 83 | if twitterClient.user != nil { 84 | ActionBarView(replying: $replying) 85 | } else { 86 | Button(action: { twitterClient.signIn() }) { 87 | Label("Sign In With Twitter", image: "twitter.fill") 88 | .font(.broadcastHeadline) 89 | } 90 | .buttonStyle(BroadcastButtonStyle()) 91 | .accessibilityIdentifier("loginButton") 92 | } 93 | } 94 | .padding() 95 | .animation(.springAnimation) 96 | .background( 97 | VisualEffectView(effect: UIBlurEffect(style: .regular)) 98 | .ignoresSafeArea() 99 | .opacity(twitterClient.user == nil ? 0 : 1) 100 | ) 101 | .gesture(DragGesture().onEnded({ _ in UIApplication.shared.endEditing() })) 102 | } 103 | .sheet(isPresented: $signOutScreenIsPresented) { 104 | SignOutView() 105 | } 106 | .sheet(isPresented: $repliesSheetIsPresented) { 107 | RepliesListView(tweet: twitterClient.lastTweet) 108 | .accentColor(ThemeHelper.shared.color) 109 | .font(.broadcastBody) 110 | } 111 | .onAppear { 112 | UITextView.appearance().backgroundColor = .clear 113 | } 114 | .onChange(of: replying) { _ in 115 | twitterClient.revalidateAccount() 116 | } 117 | .onPreferenceChange(ReplyBoxSizePreferenceKey.self) { newValue in 118 | withAnimation(.easeInOut(duration: 0.1)) { replyBoxHeight = newValue } 119 | } 120 | } 121 | } 122 | } 123 | 124 | extension ContentView { 125 | struct ReplyBoxSizePreferenceKey: PreferenceKey { 126 | static let defaultValue: CGFloat = 0 127 | 128 | static func reduce(value: inout CGFloat, 129 | nextValue: () -> CGFloat) { 130 | value = max(value, nextValue()) 131 | } 132 | } 133 | } 134 | 135 | struct ContentView_Previews: PreviewProvider { 136 | static var previews: some View { 137 | ContentView() 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /Broadcast/Helper Views/ComposerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ComposerView.swift 3 | // Broadcast 4 | // 5 | // Created by Daniel Eden on 30/06/2021. 6 | // 7 | 8 | import SwiftUI 9 | import TwitterText 10 | 11 | fileprivate let placeholderCandidates: [String] = [ 12 | "Wh—what’s going on?", 13 | "Oh hey uh, what’s up", 14 | "What’s Twitter?", 15 | "Tweet away, sweet child", 16 | "Say something nice", 17 | "Cowabunga, dude", 18 | "You’re doing a great job" 19 | ] 20 | 21 | struct ComposerView: View { 22 | let debouncer = Debouncer(timeInterval: 0.3) 23 | @Binding var signOutScreenIsPresented: Bool 24 | 25 | @EnvironmentObject var twitterClient: TwitterClient 26 | @ScaledMetric private var minComposerHeight: CGFloat = 120 27 | @ScaledMetric private var captionSize: CGFloat = 14 28 | @ScaledMetric private var leftOffset: CGFloat = 4 29 | @ScaledMetric private var verticalPadding: CGFloat = 6 30 | 31 | @State private var placeholder: String = placeholderCandidates.randomElement() 32 | @State private var draftListVisible = false 33 | 34 | private let mentioningRegex = NSRegularExpression("@[a-z0-9_]+$", options: .caseInsensitive) 35 | 36 | private var tweetText: String { 37 | twitterClient.draft.text ?? "" 38 | } 39 | 40 | private var mentionString: String? { 41 | mentioningRegex.firstMatchAsString(tweetText) 42 | } 43 | 44 | private var charCount: Int { 45 | TwitterText.tweetLength(text: tweetText) 46 | } 47 | 48 | private var mentionCandidates: [TwitterClient.User]? { 49 | twitterClient.userSearchResults 50 | } 51 | 52 | private var tweetLengthWarning: String { 53 | switch charCount { 54 | case 380...479: 55 | return " (ok c’mon dude)" 56 | case 480...679: 57 | return " (this isn’t the notes app)" 58 | case 680...1023: 59 | return " (the app probably looks pretty bad right now, huh)" 60 | case 1024...2047: 61 | return " (I bet you’re wondering when this will stop)" 62 | case 2048...19999: 63 | return " (you’re really gonna keep going?)" 64 | case 20000...30000: 65 | return " (I’m impressed, really)" 66 | case 30000...42348: 67 | return " (almost there)" 68 | case 42349: 69 | return " (nice)" 70 | case 42350...Int.max: 71 | return " (fin)" 72 | default: 73 | return "" 74 | } 75 | } 76 | 77 | var body: some View { 78 | ZStack(alignment: .bottom) { 79 | VStack(alignment: .trailing) { 80 | HStack(alignment: .top) { 81 | if let profileImageURL = twitterClient.user?.profileImageURL { 82 | RemoteImage(url: profileImageURL, placeholder: { ProgressView() }) 83 | .aspectRatio(contentMode: .fill) 84 | .frame(width: 36, height: 36) 85 | .cornerRadius(36) 86 | .onTapGesture { 87 | signOutScreenIsPresented = true 88 | UIApplication.shared.endEditing() 89 | } 90 | .accessibilityIdentifier("profilePhotoButton") 91 | } 92 | 93 | ZStack(alignment: .topLeading) { 94 | Text(tweetText.isEmpty ? placeholder : tweetText) 95 | .padding(.leading, leftOffset) 96 | .padding(.vertical, verticalPadding) 97 | .foregroundColor(Color(.placeholderText)) 98 | .opacity(tweetText.isEmpty ? 1 : 0) 99 | .accessibility(hidden: true) 100 | 101 | if #available(iOS 16.0, *) { 102 | TextEditor(text: Binding($twitterClient.draft.text, replacingNilWith: "")) 103 | .foregroundColor(Color(.label)) 104 | .multilineTextAlignment(.leading) 105 | .keyboardType(.twitter) 106 | .padding(.top, (verticalPadding / 3) * -1) 107 | .accessibilityIdentifier("tweetComposer") 108 | .scrollContentBackground(.hidden) 109 | } else { 110 | TextEditor(text: Binding($twitterClient.draft.text, replacingNilWith: "")) 111 | .foregroundColor(Color(.label)) 112 | .multilineTextAlignment(.leading) 113 | .keyboardType(.twitter) 114 | .padding(.top, (verticalPadding / 3) * -1) 115 | .accessibilityIdentifier("tweetComposer") 116 | } 117 | } 118 | .font(.broadcastTitle3) 119 | }.transition(.scale) 120 | 121 | Divider() 122 | 123 | HStack(alignment: .top) { 124 | Menu { 125 | Button(action: { twitterClient.saveDraft() }) { 126 | Label("Save Draft", systemImage: "square.and.pencil") 127 | }.disabled(!twitterClient.draft.isValid) 128 | 129 | Button(action: { draftListVisible = true }) { 130 | Label("View Drafts", systemImage: "doc.on.doc") 131 | } 132 | } label: { 133 | Label("Drafts", systemImage: "doc.on.doc") 134 | .font(.broadcastFootnote) 135 | } 136 | 137 | Spacer() 138 | 139 | Text("\(280 - charCount)\(tweetLengthWarning)") 140 | .foregroundColor(charCount > 200 ? charCount >= 280 ? Color(.systemRed) : Color(.systemOrange) : .secondary) 141 | .font(.system(size: min(captionSize * max(CGFloat(charCount) / 280, 1), 28), weight: .bold, design: .rounded)) 142 | .multilineTextAlignment(.trailing) 143 | } 144 | } 145 | .disabled(twitterClient.state == .busy) 146 | .padding() 147 | .background(Color(.tertiarySystemGroupedBackground)) 148 | .onShake { 149 | rotatePlaceholder() 150 | Haptics.shared.sendStandardFeedback(feedbackType: .success) 151 | } 152 | .onChange(of: twitterClient.draft.isValid) { isValid in 153 | if !isValid && charCount > 280 { 154 | Haptics.shared.sendStandardFeedback(feedbackType: .warning) 155 | } 156 | } 157 | .sheet(isPresented: $draftListVisible) { 158 | DraftsListView() 159 | .environmentObject(ThemeHelper.shared) 160 | .environment(\.managedObjectContext, PersistanceController.shared.context) 161 | } 162 | .onChange(of: mentionString) { value in 163 | if let screenName = value { 164 | debouncer.renewInterval() 165 | debouncer.handler = { 166 | self.twitterClient.searchScreenNames(screenName) 167 | } 168 | } 169 | } 170 | 171 | if let users = mentionCandidates, 172 | !users.isEmpty, 173 | let mentionString = mentionString, 174 | !mentionString.isEmpty { 175 | MentionBar(users: users) { user in 176 | completeMention(user) 177 | } 178 | } 179 | }.cornerRadius(captionSize) 180 | } 181 | 182 | func completeMention(_ user: TwitterClient.User) { 183 | let textToComplete = mentioningRegex.firstMatchAsString(tweetText) ?? "" 184 | let draft = twitterClient.draft.text?.replacingOccurrences(of: textToComplete, with: "@\(user.screenName) ") 185 | twitterClient.draft.text = draft 186 | } 187 | 188 | func rotatePlaceholder() { 189 | var newPlaceholder = placeholder 190 | 191 | while newPlaceholder == placeholder { 192 | newPlaceholder = placeholderCandidates.randomElement() 193 | } 194 | 195 | placeholder = newPlaceholder 196 | } 197 | } 198 | 199 | struct ComposerView_Previews: PreviewProvider { 200 | static var previews: some View { 201 | ComposerView(signOutScreenIsPresented: .constant(false)) 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /Broadcast/Helpers/TwitterClient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TwitterClient.swift 3 | // Broadcast 4 | // 5 | // Created by Daniel Eden on 30/06/2021. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | import Swifter 11 | import TwitterText 12 | import UIKit 13 | import AuthenticationServices 14 | import SwiftKeychainWrapper 15 | import SwiftUI 16 | 17 | let typeaheadToken = "AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA" 18 | 19 | class TwitterClient: NSObject, ObservableObject { 20 | let draftsStore = PersistanceController.shared 21 | @Published var user: User? 22 | @Published var draft = Tweet() 23 | @Published var state: State = .idle 24 | @Published var lastTweet: Tweet? 25 | 26 | private var client = Swifter.init(consumerKey: ClientCredentials.apiKey, consumerSecret: ClientCredentials.apiSecret) 27 | 28 | override init() { 29 | super.init() 30 | 31 | if let storedCredentials = retreiveCredentials() { 32 | self.client.client.credential = .init(accessToken: storedCredentials) 33 | if let userId = storedCredentials.userID, 34 | let screenName = storedCredentials.screenName { 35 | self.user = .init(id: userId, screenName: screenName) 36 | self.revalidateAccount() 37 | } 38 | } 39 | } 40 | 41 | func signIn() { 42 | DispatchQueue.main.async { self.state = .busy } 43 | client.authorize(withProvider: self, callbackURL: ClientCredentials.callbackURL) { credentials, response in 44 | guard let credentials = credentials, 45 | let id = credentials.userID, 46 | let screenName = credentials.screenName else { 47 | self.state = .error("Yikes, something when wrong when trying to sign in") 48 | return 49 | } 50 | 51 | self.storeCredentials(credentials: credentials) 52 | 53 | DispatchQueue.main.async { 54 | self.state = .idle 55 | self.user = User(id: id, screenName: screenName) 56 | self.revalidateAccount() 57 | } 58 | } 59 | } 60 | 61 | func revalidateAccount() { 62 | guard let userId = user?.id else { 63 | self.signOut() 64 | return 65 | } 66 | 67 | client.showUser(.id(userId), tweetMode: .extended) { json in 68 | /** If the `showUser` call was successful, we can reuse the result to update the user’s profile photo */ 69 | guard let urlString = json["profile_image_url_https"].string else { 70 | return 71 | } 72 | 73 | withAnimation { 74 | self.user?.originalProfileImageURL = URL(string: urlString.replacingOccurrences(of: "_normal", with: "")) 75 | } 76 | 77 | self.updateLastTweet(from: json["status"]) 78 | } failure: { error in 79 | self.signOut() 80 | self.updateState(.error("Yikes; there was a problem signing in to Twitter. You’ll have to try signing in again.")) 81 | } 82 | } 83 | 84 | func signOut() { 85 | self.user = nil 86 | self.draft = .init() 87 | self.lastTweet = nil 88 | KeychainWrapper.standard.remove(forKey: "broadcast-credentials") 89 | } 90 | 91 | func storeCredentials(credentials: Credential.OAuthAccessToken) { 92 | guard let data = credentials.data else { 93 | return 94 | } 95 | 96 | KeychainWrapper.standard.set(data, forKey: "broadcast-credentials") 97 | } 98 | 99 | func retreiveCredentials() -> Credential.OAuthAccessToken? { 100 | if isTestEnvironment { 101 | return .init(queryString: ClientCredentials.__authQueryString) 102 | } 103 | 104 | guard let data = KeychainWrapper.standard.data(forKey: "broadcast-credentials") else { 105 | return nil 106 | } 107 | 108 | return .init(from: data) 109 | } 110 | 111 | private func sendTweetCallback(response: JSON? = nil, error: Error? = nil) { 112 | if let json = response { 113 | self.updateLastTweet(from: json) 114 | self.updateState(.idle) 115 | self.draft = .init() 116 | Haptics.shared.sendStandardFeedback(feedbackType: .success) 117 | } else if let error = error { 118 | print(error.localizedDescription) 119 | 120 | if draft.media != nil { 121 | self.updateState(.genericTextAndMediaError) 122 | } else { 123 | self.updateState(.genericTextError) 124 | } 125 | 126 | Haptics.shared.sendStandardFeedback(feedbackType: .error) 127 | } 128 | } 129 | 130 | func sendTweet() { 131 | updateState(.busy) 132 | 133 | if let mediaData = draft.media?.jpegData(compressionQuality: 0.8) { 134 | client.postTweet(status: draft.text ?? "", media: mediaData) { json in 135 | self.sendTweetCallback(response: json) 136 | } failure: { error in 137 | print(error.localizedDescription) 138 | self.sendTweetCallback(error: error) 139 | } 140 | } else if let status = draft.text { 141 | client.postTweet(status: status) { json in 142 | self.sendTweetCallback(response: json) 143 | } failure: { error in 144 | print(error.localizedDescription) 145 | self.sendTweetCallback(error: error) 146 | } 147 | } 148 | } 149 | 150 | func sendReply(to id: String) { 151 | updateState(.busy) 152 | 153 | if let mediaData = draft.media?.jpegData(compressionQuality: 0.8) { 154 | client.postTweet(status: draft.text ?? "", media: mediaData, inReplyToStatusID: id) { json in 155 | self.sendTweetCallback(response: json) 156 | } failure: { error in 157 | self.sendTweetCallback(error: error) 158 | } 159 | } else if let status = draft.text { 160 | client.postTweet(status: status, inReplyToStatusID: id) { json in 161 | self.sendTweetCallback(response: json) 162 | Haptics.shared.sendStandardFeedback(feedbackType: .success) 163 | } failure: { error in 164 | self.sendTweetCallback(error: error) 165 | } 166 | } 167 | } 168 | 169 | private func updateLastTweet(from json: JSON) { 170 | guard let id = json["id_str"].string else { return } 171 | var lastTweet = Tweet(id: id) 172 | lastTweet.text = json["full_text"].string ?? json["text"].string 173 | lastTweet.likes = json["favorite_count"].integer 174 | lastTweet.retweets = json["retweet_count"].integer 175 | lastTweet.numericId = json["id"].integer 176 | 177 | if !isTestEnvironment { 178 | self.getReplies(for: lastTweet) { replies in 179 | lastTweet.replies = replies 180 | self.lastTweet = lastTweet 181 | } 182 | } 183 | } 184 | 185 | /// Asynchronously update client state on the main thread 186 | /// - Parameter newState: The new state for the client 187 | private func updateState(_ newState: State) { 188 | DispatchQueue.main.async { 189 | self.state = newState 190 | } 191 | } 192 | 193 | @Published var userSearchResults: [User]? 194 | private var userSearchCancellables = [AnyCancellable]() 195 | func searchScreenNames(_ screenName: String) { 196 | let url = URL(string: "https://twitter.com/i/search/typeahead.json?count=10&q=%23\(screenName)&result_type=users")! 197 | 198 | var headers = [ 199 | "Authorization": typeaheadToken 200 | ] 201 | 202 | if let userId = user?.id, 203 | let token = client.client.credential?.accessToken?.key { 204 | headers["Cookie"] = "twid=u%3D\(userId);auth_token=\(token)" 205 | } 206 | 207 | var request = URLRequest(url: url) 208 | request.allHTTPHeaderFields = headers 209 | request.httpShouldHandleCookies = true 210 | 211 | URLSession.shared.dataTaskPublisher(for: request) 212 | .tryMap() { element -> Data in 213 | guard let httpResponse = element.response as? HTTPURLResponse, 214 | httpResponse.statusCode == 200 else { 215 | throw URLError(.badServerResponse) 216 | } 217 | return element.data 218 | } 219 | .decode(type: TypeaheadResponse.self, decoder: JSONDecoder()) 220 | .sink { completion in 221 | switch completion { 222 | case .failure(let error): 223 | print(error.localizedDescription) 224 | default: 225 | return 226 | } 227 | } receiveValue: { result in 228 | DispatchQueue.main.async { 229 | self.userSearchResults = result.users 230 | } 231 | }.store(in: &userSearchCancellables) 232 | } 233 | 234 | /// Asynchronously provides up to 200 replies for the given tweet. This method works by fetching the 235 | /// most recent 200 @mentions for the user and filters the result to those replying to the provided tweet. 236 | /// - Parameters: 237 | /// - tweet: The tweet to fetch replies for 238 | /// - completion: A callback for handling the replies 239 | private func getReplies(for tweet: Tweet, completion: @escaping ([Tweet]) -> Void = { _ in }) { 240 | let formatter = DateFormatter() 241 | formatter.dateFormat = "EE MMM dd HH:mm:ss Z yyyy" 242 | 243 | guard let tweetId = tweet.id else { return } 244 | 245 | client.getMentionsTimelineTweets(count: 200, tweetMode: .extended) { json in 246 | guard let repliesResult = json.array else { return } 247 | let repliesToThisTweet: [Tweet?] = repliesResult.filter { json in 248 | guard let replyId = json["in_reply_to_status_id"].integer else { return false } 249 | return replyId == tweet.numericId 250 | }.map { json in 251 | guard let id = json["id_str"].string, 252 | let text = json["full_text"].string, 253 | let dateString = json["created_at"].string, 254 | let date = formatter.date(from: dateString) else { 255 | return nil 256 | } 257 | 258 | let user = User(from: json["user"]) 259 | return Tweet(id: id, text: text, date: date, author: user) 260 | } 261 | 262 | completion(repliesToThisTweet.compactMap { $0 }) 263 | 264 | } failure: { error in 265 | print("Error fetching replies for Tweet with ID \(tweetId)") 266 | print(error.localizedDescription) 267 | } 268 | } 269 | } 270 | 271 | extension TwitterClient: ASWebAuthenticationPresentationContextProviding { 272 | func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { 273 | return ASPresentationAnchor() 274 | } 275 | } 276 | 277 | /* MARK: Drafts */ 278 | extension TwitterClient { 279 | /// Saves the current draft to CoreData for later retrieval. This method also resets/clears the current draft. 280 | func saveDraft() { 281 | guard draft.isValid else { return } 282 | let copy = draft 283 | 284 | DispatchQueue.global(qos: .default).async { 285 | let newDraft = Draft.init(context: self.draftsStore.context) 286 | newDraft.date = Date() 287 | newDraft.text = copy.text 288 | newDraft.media = copy.media?.fixedOrientation.pngData() 289 | newDraft.id = UUID() 290 | 291 | self.draftsStore.save() 292 | } 293 | 294 | withAnimation { 295 | self.draft = .init() 296 | self.state = .idle 297 | } 298 | } 299 | 300 | /// Retrieve the specified draft from CoreData, storing it in memory and deleting it from the CoreData database 301 | /// - Parameter draft: The chosen draft for retrieval and deletion 302 | func retreiveDraft(draft: Draft) { 303 | withAnimation { 304 | self.draft = Tweet(text: draft.text) 305 | 306 | if let media = draft.media { 307 | self.draft.media = UIImage(data: media) 308 | } 309 | } 310 | 311 | let managedObjectContext = PersistanceController.shared.context 312 | managedObjectContext.delete(draft) 313 | 314 | PersistanceController.shared.save() 315 | } 316 | } 317 | 318 | // MARK: Models 319 | extension TwitterClient { 320 | enum State: Equatable { 321 | case idle, busy 322 | case error(_: String? = nil) 323 | 324 | static var genericTextError = State.error("Oh man, something went wrong sending that tweet. It might be too long.") 325 | 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.") 326 | } 327 | 328 | struct ClientCredentials { 329 | static private var plist: NSDictionary? { 330 | guard let filePath = Bundle.main.path(forResource: "TwitterAPI-Info", ofType: "plist") else { 331 | fatalError("Couldn't find file 'TwitterAPI-Info.plist'.") 332 | } 333 | // 2 334 | return NSDictionary(contentsOfFile: filePath) 335 | } 336 | 337 | static var apiKey: String { 338 | guard let value = plist?.object(forKey: "API_KEY") as? String else { 339 | fatalError("Couldn't find key 'API_KEY' in 'TwitterAPI-Info.plist'.") 340 | } 341 | 342 | return value 343 | } 344 | 345 | static var apiSecret: String { 346 | guard let value = plist?.object(forKey: "API_SECRET") as? String else { 347 | fatalError("Couldn't find key 'API_KEY' in 'TwitterAPI-Info.plist'.") 348 | } 349 | 350 | return value 351 | } 352 | 353 | static var __authQueryString: String { 354 | guard let value = plist?.object(forKey: "__TEST_AUTH_QUERY_STRING") as? String else { 355 | fatalError("Couldn't find key '__TEST_AUTH_QUERY_STRING' in 'TwitterAPI-Info.plist'.") 356 | } 357 | 358 | return value 359 | } 360 | 361 | static var callbackProtocol = "twitter-broadcast://" 362 | static var callbackURL: URL { 363 | URL(string: callbackProtocol)! 364 | } 365 | } 366 | 367 | struct User: Decodable { 368 | var id: String 369 | var screenName: String 370 | var name: String? 371 | var originalProfileImageURL: URL? 372 | var profileImageURL: URL? { 373 | if let urlString = originalProfileImageURL?.absoluteString.replacingOccurrences(of: "_normal", with: "_x96") { 374 | return URL(string: urlString) 375 | } else { 376 | return originalProfileImageURL 377 | } 378 | } 379 | 380 | enum CodingKeys: String, CodingKey { 381 | case screenName = "screen_name" 382 | case originalProfileImageURL = "profile_image_url_https" 383 | case id = "id_str" 384 | case name 385 | } 386 | } 387 | 388 | struct Tweet { 389 | var numericId: Int? 390 | var id: String? 391 | var text: String? 392 | var media: UIImage? 393 | 394 | var likes: Int? 395 | var retweets: Int? 396 | var replies: [Tweet]? 397 | 398 | var date: Date? 399 | 400 | var length: Int { 401 | TwitterText.tweetLength(text: text ?? "") 402 | } 403 | 404 | var isValid: Bool { 405 | if media != nil { 406 | return true 407 | } 408 | 409 | return 1...280 ~= length && !(text ?? "").isBlank 410 | } 411 | 412 | var author: User? 413 | } 414 | } 415 | 416 | extension TwitterClient.User { 417 | init(from json: JSON) { 418 | self.name = json["name"].string 419 | self.screenName = json["screen_name"].string ?? "TwitterUser" 420 | self.id = json["id_str"].string ?? "" 421 | let imageUrlString = json["profile_image_url_https"].string ?? "" 422 | self.originalProfileImageURL = URL(string: imageUrlString) 423 | } 424 | } 425 | 426 | extension Credential.OAuthAccessToken { 427 | var data: Data? { 428 | let dict = [ 429 | "key": key, 430 | "secret": secret, 431 | "userId": userID, 432 | "screenName": screenName 433 | ] 434 | 435 | return try? JSONSerialization.data(withJSONObject: dict, options: []) 436 | } 437 | 438 | init?(from data: Data) { 439 | do { 440 | let dict = try JSONDecoder().decode([String: String].self, from: data) 441 | guard let key = dict["key"], 442 | let secret = dict["secret"] else { 443 | return nil 444 | } 445 | 446 | let screenName = dict["screenName"] 447 | let userId = dict["userId"] 448 | 449 | let queryString = "oauth_token=\(key)&oauth_token_secret=\(secret)&screen_name=\(screenName ?? "")&user_id=\(userId ?? "")" 450 | 451 | self.init(queryString: queryString) 452 | } catch let error { 453 | print(error.localizedDescription) 454 | return nil 455 | } 456 | } 457 | } 458 | 459 | struct TypeaheadResponse: Decodable { 460 | var num_results: Int 461 | var users: [TwitterClient.User]? 462 | } 463 | -------------------------------------------------------------------------------- /Broadcast.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 52; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 7101073626C810AC00A713A5 /* NullStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7101073526C810AC00A713A5 /* NullStateView.swift */; }; 11 | 711EF99426C959A700FD8A9F /* BroadcastUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711EF99326C959A700FD8A9F /* BroadcastUITests.swift */; }; 12 | 711F3FFA268F50C800605C89 /* Animation.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711F3FF9268F50C800605C89 /* Animation.extension.swift */; }; 13 | 715AAE0A26C923A1002BCEA1 /* Swifter in Frameworks */ = {isa = PBXBuildFile; productRef = 715AAE0926C923A1002BCEA1 /* Swifter */; }; 14 | 715AAE0C26C9589A002BCEA1 /* TestUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 715AAE0B26C9589A002BCEA1 /* TestUtils.swift */; }; 15 | 717041F326B6FFEA00001360 /* RepliesListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 717041F226B6FFEA00001360 /* RepliesListView.swift */; }; 16 | 717041F526B7037300001360 /* TweetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 717041F426B7037300001360 /* TweetView.swift */; }; 17 | 717041F726B703A600001360 /* TwitterClient+MockTweet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 717041F626B703A600001360 /* TwitterClient+MockTweet.swift */; }; 18 | 71800BF026905DB3009D11A1 /* EngagementCountersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71800BEF26905DB3009D11A1 /* EngagementCountersView.swift */; }; 19 | 71800BF226906721009D11A1 /* ActionBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71800BF126906721009D11A1 /* ActionBarView.swift */; }; 20 | 71800BF426906BC0009D11A1 /* DraftsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71800BF326906BC0009D11A1 /* DraftsListView.swift */; }; 21 | 71800BF8269998BF009D11A1 /* UIImage.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71800BF7269998BF009D11A1 /* UIImage.extension.swift */; }; 22 | 71800BFD26999B1B009D11A1 /* DraftsModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 71800BFB26999B1B009D11A1 /* DraftsModel.xcdatamodeld */; }; 23 | 71800BFF26999BA6009D11A1 /* PersistanceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71800BFE26999BA6009D11A1 /* PersistanceController.swift */; }; 24 | 7188E6292687B0FE007CFD78 /* BroadcastApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E6282687B0FE007CFD78 /* BroadcastApp.swift */; }; 25 | 7188E62B2687B0FE007CFD78 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E62A2687B0FE007CFD78 /* ContentView.swift */; }; 26 | 7188E62D2687B0FF007CFD78 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7188E62C2687B0FF007CFD78 /* Assets.xcassets */; }; 27 | 7188E6302687B0FF007CFD78 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7188E62F2687B0FF007CFD78 /* Preview Assets.xcassets */; }; 28 | 7188E63B2687B19D007CFD78 /* Notification.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E63A2687B19D007CFD78 /* Notification.extension.swift */; }; 29 | 7188E6472687B6C2007CFD78 /* SafariView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E6462687B6C2007CFD78 /* SafariView.swift */; }; 30 | 7188E6492687C0E0007CFD78 /* BroadcastButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E6482687C0E0007CFD78 /* BroadcastButtonStyle.swift */; }; 31 | 7188E64B2687C44D007CFD78 /* String.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E64A2687C44D007CFD78 /* String.extension.swift */; }; 32 | 7188E64D2687C6A1007CFD78 /* Binding.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E64C2687C6A1007CFD78 /* Binding.extension.swift */; }; 33 | 7188E64F2687CCD0007CFD78 /* PhotoPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E64E2687CCD0007CFD78 /* PhotoPicker.swift */; }; 34 | 7188E6532687D16C007CFD78 /* AttachmentThumbnail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E6522687D16C007CFD78 /* AttachmentThumbnail.swift */; }; 35 | 7188E65D26887DCD007CFD78 /* SwiftKeychainWrapper in Frameworks */ = {isa = PBXBuildFile; productRef = 7188E65C26887DCD007CFD78 /* SwiftKeychainWrapper */; }; 36 | 7188E65F26889147007CFD78 /* SignOutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E65E26889147007CFD78 /* SignOutView.swift */; }; 37 | 7188E6612688A01F007CFD78 /* Font.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E6602688A01F007CFD78 /* Font.extension.swift */; }; 38 | 7188E6632688A0FC007CFD78 /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E6622688A0FC007CFD78 /* WelcomeView.swift */; }; 39 | 7188E6652688A436007CFD78 /* ThemeHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E6642688A436007CFD78 /* ThemeHelper.swift */; }; 40 | 7188E6672688B99E007CFD78 /* VisualEffectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E6662688B99E007CFD78 /* VisualEffectView.swift */; }; 41 | 7188E66A2688D7BA007CFD78 /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 7188E6692688D7BA007CFD78 /* Introspect */; }; 42 | 7188E66C2688DB44007CFD78 /* UIApplication.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E66B2688DB44007CFD78 /* UIApplication.extension.swift */; }; 43 | 719087CD26891586005B96CE /* TwitterText in Frameworks */ = {isa = PBXBuildFile; productRef = 719087CC26891586005B96CE /* TwitterText */; }; 44 | 719087CF26891C7F005B96CE /* Array.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 719087CE26891C7F005B96CE /* Array.extension.swift */; }; 45 | 7199AE7A26B96D0D001DEB46 /* NSRegularExpression+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7199AE7926B96D0D001DEB46 /* NSRegularExpression+Convenience.swift */; }; 46 | 7199AE7C26B97327001DEB46 /* UserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7199AE7B26B97327001DEB46 /* UserView.swift */; }; 47 | 7199AE7E26B973B2001DEB46 /* MentionBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7199AE7D26B973B2001DEB46 /* MentionBar.swift */; }; 48 | 7199AE8026B9892E001DEB46 /* Debouncer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7199AE7F26B9892E001DEB46 /* Debouncer.swift */; }; 49 | 71A1088A268B073B007E1FFB /* Haptics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71A10889268B073B007E1FFB /* Haptics.swift */; }; 50 | 71A6A264278C73AD00BF2387 /* TwitterAPI-Info.example.plist in Resources */ = {isa = PBXBuildFile; fileRef = 71A6A263278C73AD00BF2387 /* TwitterAPI-Info.example.plist */; }; 51 | 71AA4AC6268A032400B7B577 /* RemoteImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71AA4AC5268A032400B7B577 /* RemoteImage.swift */; }; 52 | 71B8290C268D0AC6002AEE72 /* TwitterClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71B8290B268D0AC6002AEE72 /* TwitterClient.swift */; }; 53 | 71B82910268F2195002AEE72 /* LastTweetReplyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71B8290F268F2195002AEE72 /* LastTweetReplyView.swift */; }; 54 | 71BBAAE9268CF1BD004048A0 /* ComposerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71BBAAE8268CF1BD004048A0 /* ComposerView.swift */; }; 55 | 71BBAAEB268CF532004048A0 /* TwitterAPI-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 71BBAAEA268CF532004048A0 /* TwitterAPI-Info.plist */; }; 56 | 71E36FFB2689EED40078D956 /* ShakeModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71E36FFA2689EED40078D956 /* ShakeModifier.swift */; }; 57 | /* End PBXBuildFile section */ 58 | 59 | /* Begin PBXContainerItemProxy section */ 60 | 711EF99626C959A700FD8A9F /* PBXContainerItemProxy */ = { 61 | isa = PBXContainerItemProxy; 62 | containerPortal = 7188E61D2687B0FE007CFD78 /* Project object */; 63 | proxyType = 1; 64 | remoteGlobalIDString = 7188E6242687B0FE007CFD78; 65 | remoteInfo = Broadcast; 66 | }; 67 | /* End PBXContainerItemProxy section */ 68 | 69 | /* Begin PBXFileReference section */ 70 | 7101073526C810AC00A713A5 /* NullStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NullStateView.swift; sourceTree = ""; }; 71 | 711EF99126C959A700FD8A9F /* BroadcastUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BroadcastUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 72 | 711EF99326C959A700FD8A9F /* BroadcastUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastUITests.swift; sourceTree = ""; }; 73 | 711EF99526C959A700FD8A9F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 74 | 711F3FF9268F50C800605C89 /* Animation.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Animation.extension.swift; sourceTree = ""; }; 75 | 715AAE0B26C9589A002BCEA1 /* TestUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestUtils.swift; sourceTree = ""; }; 76 | 717041F226B6FFEA00001360 /* RepliesListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepliesListView.swift; sourceTree = ""; }; 77 | 717041F426B7037300001360 /* TweetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TweetView.swift; sourceTree = ""; }; 78 | 717041F626B703A600001360 /* TwitterClient+MockTweet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TwitterClient+MockTweet.swift"; sourceTree = ""; }; 79 | 71800BEF26905DB3009D11A1 /* EngagementCountersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementCountersView.swift; sourceTree = ""; }; 80 | 71800BF126906721009D11A1 /* ActionBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionBarView.swift; sourceTree = ""; }; 81 | 71800BF326906BC0009D11A1 /* DraftsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftsListView.swift; sourceTree = ""; }; 82 | 71800BF7269998BF009D11A1 /* UIImage.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.extension.swift; sourceTree = ""; }; 83 | 71800BFC26999B1B009D11A1 /* DraftsModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = DraftsModel.xcdatamodel; sourceTree = ""; }; 84 | 71800BFE26999BA6009D11A1 /* PersistanceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistanceController.swift; sourceTree = ""; }; 85 | 7188E6252687B0FE007CFD78 /* Broadcast.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Broadcast.app; sourceTree = BUILT_PRODUCTS_DIR; }; 86 | 7188E6282687B0FE007CFD78 /* BroadcastApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastApp.swift; sourceTree = ""; }; 87 | 7188E62A2687B0FE007CFD78 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 88 | 7188E62C2687B0FF007CFD78 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 89 | 7188E62F2687B0FF007CFD78 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 90 | 7188E6312687B0FF007CFD78 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 91 | 7188E63A2687B19D007CFD78 /* Notification.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notification.extension.swift; sourceTree = ""; }; 92 | 7188E6462687B6C2007CFD78 /* SafariView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariView.swift; sourceTree = ""; }; 93 | 7188E6482687C0E0007CFD78 /* BroadcastButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastButtonStyle.swift; sourceTree = ""; }; 94 | 7188E64A2687C44D007CFD78 /* String.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.extension.swift; sourceTree = ""; }; 95 | 7188E64C2687C6A1007CFD78 /* Binding.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Binding.extension.swift; sourceTree = ""; }; 96 | 7188E64E2687CCD0007CFD78 /* PhotoPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPicker.swift; sourceTree = ""; }; 97 | 7188E6522687D16C007CFD78 /* AttachmentThumbnail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentThumbnail.swift; sourceTree = ""; }; 98 | 7188E65E26889147007CFD78 /* SignOutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignOutView.swift; sourceTree = ""; }; 99 | 7188E6602688A01F007CFD78 /* Font.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Font.extension.swift; sourceTree = ""; }; 100 | 7188E6622688A0FC007CFD78 /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; }; 101 | 7188E6642688A436007CFD78 /* ThemeHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeHelper.swift; sourceTree = ""; }; 102 | 7188E6662688B99E007CFD78 /* VisualEffectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectView.swift; sourceTree = ""; }; 103 | 7188E66B2688DB44007CFD78 /* UIApplication.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplication.extension.swift; sourceTree = ""; }; 104 | 719087CE26891C7F005B96CE /* Array.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.extension.swift; sourceTree = ""; }; 105 | 7199AE7926B96D0D001DEB46 /* NSRegularExpression+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSRegularExpression+Convenience.swift"; sourceTree = ""; }; 106 | 7199AE7B26B97327001DEB46 /* UserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserView.swift; sourceTree = ""; }; 107 | 7199AE7D26B973B2001DEB46 /* MentionBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionBar.swift; sourceTree = ""; }; 108 | 7199AE7F26B9892E001DEB46 /* Debouncer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debouncer.swift; sourceTree = ""; }; 109 | 71A10889268B073B007E1FFB /* Haptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Haptics.swift; sourceTree = ""; }; 110 | 71A6A263278C73AD00BF2387 /* TwitterAPI-Info.example.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "TwitterAPI-Info.example.plist"; sourceTree = ""; }; 111 | 71AA4AC5268A032400B7B577 /* RemoteImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImage.swift; sourceTree = ""; }; 112 | 71B8290B268D0AC6002AEE72 /* TwitterClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwitterClient.swift; sourceTree = ""; }; 113 | 71B8290F268F2195002AEE72 /* LastTweetReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastTweetReplyView.swift; sourceTree = ""; }; 114 | 71BBAAE8268CF1BD004048A0 /* ComposerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerView.swift; sourceTree = ""; }; 115 | 71BBAAEA268CF532004048A0 /* TwitterAPI-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "TwitterAPI-Info.plist"; sourceTree = ""; }; 116 | 71E36FFA2689EED40078D956 /* ShakeModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShakeModifier.swift; sourceTree = ""; }; 117 | /* End PBXFileReference section */ 118 | 119 | /* Begin PBXFrameworksBuildPhase section */ 120 | 711EF98E26C959A700FD8A9F /* Frameworks */ = { 121 | isa = PBXFrameworksBuildPhase; 122 | buildActionMask = 2147483647; 123 | files = ( 124 | ); 125 | runOnlyForDeploymentPostprocessing = 0; 126 | }; 127 | 7188E6222687B0FE007CFD78 /* Frameworks */ = { 128 | isa = PBXFrameworksBuildPhase; 129 | buildActionMask = 2147483647; 130 | files = ( 131 | 7188E65D26887DCD007CFD78 /* SwiftKeychainWrapper in Frameworks */, 132 | 7188E66A2688D7BA007CFD78 /* Introspect in Frameworks */, 133 | 719087CD26891586005B96CE /* TwitterText in Frameworks */, 134 | 715AAE0A26C923A1002BCEA1 /* Swifter in Frameworks */, 135 | ); 136 | runOnlyForDeploymentPostprocessing = 0; 137 | }; 138 | /* End PBXFrameworksBuildPhase section */ 139 | 140 | /* Begin PBXGroup section */ 141 | 711EF99226C959A700FD8A9F /* BroadcastUITests */ = { 142 | isa = PBXGroup; 143 | children = ( 144 | 711EF99326C959A700FD8A9F /* BroadcastUITests.swift */, 145 | 711EF99526C959A700FD8A9F /* Info.plist */, 146 | ); 147 | path = BroadcastUITests; 148 | sourceTree = ""; 149 | }; 150 | 7188E61C2687B0FE007CFD78 = { 151 | isa = PBXGroup; 152 | children = ( 153 | 7188E6272687B0FE007CFD78 /* Broadcast */, 154 | 711EF99226C959A700FD8A9F /* BroadcastUITests */, 155 | 7188E6262687B0FE007CFD78 /* Products */, 156 | ); 157 | sourceTree = ""; 158 | }; 159 | 7188E6262687B0FE007CFD78 /* Products */ = { 160 | isa = PBXGroup; 161 | children = ( 162 | 7188E6252687B0FE007CFD78 /* Broadcast.app */, 163 | 711EF99126C959A700FD8A9F /* BroadcastUITests.xctest */, 164 | ); 165 | name = Products; 166 | sourceTree = ""; 167 | }; 168 | 7188E6272687B0FE007CFD78 /* Broadcast */ = { 169 | isa = PBXGroup; 170 | children = ( 171 | 7188E6312687B0FF007CFD78 /* Info.plist */, 172 | 7188E6282687B0FE007CFD78 /* BroadcastApp.swift */, 173 | 7188E62A2687B0FE007CFD78 /* ContentView.swift */, 174 | 7188E65E26889147007CFD78 /* SignOutView.swift */, 175 | 7188E6622688A0FC007CFD78 /* WelcomeView.swift */, 176 | 7188E62C2687B0FF007CFD78 /* Assets.xcassets */, 177 | 7188E6392687B192007CFD78 /* Extensions */, 178 | 7188E6452687B6AF007CFD78 /* Helper Views */, 179 | 7188E6402687B431007CFD78 /* Helpers */, 180 | 7188E62E2687B0FF007CFD78 /* Preview Content */, 181 | 71BBAAEA268CF532004048A0 /* TwitterAPI-Info.plist */, 182 | 71A6A263278C73AD00BF2387 /* TwitterAPI-Info.example.plist */, 183 | 71800BFB26999B1B009D11A1 /* DraftsModel.xcdatamodeld */, 184 | ); 185 | path = Broadcast; 186 | sourceTree = ""; 187 | }; 188 | 7188E62E2687B0FF007CFD78 /* Preview Content */ = { 189 | isa = PBXGroup; 190 | children = ( 191 | 7188E62F2687B0FF007CFD78 /* Preview Assets.xcassets */, 192 | ); 193 | path = "Preview Content"; 194 | sourceTree = ""; 195 | }; 196 | 7188E6392687B192007CFD78 /* Extensions */ = { 197 | isa = PBXGroup; 198 | children = ( 199 | 711F3FF9268F50C800605C89 /* Animation.extension.swift */, 200 | 719087CE26891C7F005B96CE /* Array.extension.swift */, 201 | 7188E6602688A01F007CFD78 /* Font.extension.swift */, 202 | 7188E63A2687B19D007CFD78 /* Notification.extension.swift */, 203 | 7188E64A2687C44D007CFD78 /* String.extension.swift */, 204 | 7188E66B2688DB44007CFD78 /* UIApplication.extension.swift */, 205 | 71800BF7269998BF009D11A1 /* UIImage.extension.swift */, 206 | 717041F626B703A600001360 /* TwitterClient+MockTweet.swift */, 207 | 7199AE7926B96D0D001DEB46 /* NSRegularExpression+Convenience.swift */, 208 | ); 209 | path = Extensions; 210 | sourceTree = ""; 211 | }; 212 | 7188E6402687B431007CFD78 /* Helpers */ = { 213 | isa = PBXGroup; 214 | children = ( 215 | 7188E64C2687C6A1007CFD78 /* Binding.extension.swift */, 216 | 71A10889268B073B007E1FFB /* Haptics.swift */, 217 | 71E36FFA2689EED40078D956 /* ShakeModifier.swift */, 218 | 7188E6642688A436007CFD78 /* ThemeHelper.swift */, 219 | 71B8290B268D0AC6002AEE72 /* TwitterClient.swift */, 220 | 71800BFE26999BA6009D11A1 /* PersistanceController.swift */, 221 | 7199AE7F26B9892E001DEB46 /* Debouncer.swift */, 222 | 715AAE0B26C9589A002BCEA1 /* TestUtils.swift */, 223 | ); 224 | path = Helpers; 225 | sourceTree = ""; 226 | }; 227 | 7188E6452687B6AF007CFD78 /* Helper Views */ = { 228 | isa = PBXGroup; 229 | children = ( 230 | 71800BF126906721009D11A1 /* ActionBarView.swift */, 231 | 7188E6522687D16C007CFD78 /* AttachmentThumbnail.swift */, 232 | 7188E6482687C0E0007CFD78 /* BroadcastButtonStyle.swift */, 233 | 71BBAAE8268CF1BD004048A0 /* ComposerView.swift */, 234 | 71800BF326906BC0009D11A1 /* DraftsListView.swift */, 235 | 71800BEF26905DB3009D11A1 /* EngagementCountersView.swift */, 236 | 71B8290F268F2195002AEE72 /* LastTweetReplyView.swift */, 237 | 7188E64E2687CCD0007CFD78 /* PhotoPicker.swift */, 238 | 71AA4AC5268A032400B7B577 /* RemoteImage.swift */, 239 | 7188E6462687B6C2007CFD78 /* SafariView.swift */, 240 | 7188E6662688B99E007CFD78 /* VisualEffectView.swift */, 241 | 717041F226B6FFEA00001360 /* RepliesListView.swift */, 242 | 717041F426B7037300001360 /* TweetView.swift */, 243 | 7199AE7B26B97327001DEB46 /* UserView.swift */, 244 | 7199AE7D26B973B2001DEB46 /* MentionBar.swift */, 245 | 7101073526C810AC00A713A5 /* NullStateView.swift */, 246 | ); 247 | path = "Helper Views"; 248 | sourceTree = ""; 249 | }; 250 | /* End PBXGroup section */ 251 | 252 | /* Begin PBXNativeTarget section */ 253 | 711EF99026C959A700FD8A9F /* BroadcastUITests */ = { 254 | isa = PBXNativeTarget; 255 | buildConfigurationList = 711EF99A26C959A700FD8A9F /* Build configuration list for PBXNativeTarget "BroadcastUITests" */; 256 | buildPhases = ( 257 | 711EF98D26C959A700FD8A9F /* Sources */, 258 | 711EF98E26C959A700FD8A9F /* Frameworks */, 259 | 711EF98F26C959A700FD8A9F /* Resources */, 260 | ); 261 | buildRules = ( 262 | ); 263 | dependencies = ( 264 | 711EF99726C959A700FD8A9F /* PBXTargetDependency */, 265 | ); 266 | name = BroadcastUITests; 267 | productName = BroadcastUITests; 268 | productReference = 711EF99126C959A700FD8A9F /* BroadcastUITests.xctest */; 269 | productType = "com.apple.product-type.bundle.ui-testing"; 270 | }; 271 | 7188E6242687B0FE007CFD78 /* Broadcast */ = { 272 | isa = PBXNativeTarget; 273 | buildConfigurationList = 7188E6342687B0FF007CFD78 /* Build configuration list for PBXNativeTarget "Broadcast" */; 274 | buildPhases = ( 275 | 7188E6212687B0FE007CFD78 /* Sources */, 276 | 7188E6222687B0FE007CFD78 /* Frameworks */, 277 | 7188E6232687B0FE007CFD78 /* Resources */, 278 | 71B82911268F2A34002AEE72 /* ShellScript */, 279 | ); 280 | buildRules = ( 281 | ); 282 | dependencies = ( 283 | ); 284 | name = Broadcast; 285 | packageProductDependencies = ( 286 | 7188E65C26887DCD007CFD78 /* SwiftKeychainWrapper */, 287 | 7188E6692688D7BA007CFD78 /* Introspect */, 288 | 719087CC26891586005B96CE /* TwitterText */, 289 | 715AAE0926C923A1002BCEA1 /* Swifter */, 290 | ); 291 | productName = Broadcast; 292 | productReference = 7188E6252687B0FE007CFD78 /* Broadcast.app */; 293 | productType = "com.apple.product-type.application"; 294 | }; 295 | /* End PBXNativeTarget section */ 296 | 297 | /* Begin PBXProject section */ 298 | 7188E61D2687B0FE007CFD78 /* Project object */ = { 299 | isa = PBXProject; 300 | attributes = { 301 | LastSwiftUpdateCheck = 1250; 302 | LastUpgradeCheck = 1250; 303 | TargetAttributes = { 304 | 711EF99026C959A700FD8A9F = { 305 | CreatedOnToolsVersion = 12.5.1; 306 | TestTargetID = 7188E6242687B0FE007CFD78; 307 | }; 308 | 7188E6242687B0FE007CFD78 = { 309 | CreatedOnToolsVersion = 12.5.1; 310 | }; 311 | }; 312 | }; 313 | buildConfigurationList = 7188E6202687B0FE007CFD78 /* Build configuration list for PBXProject "Broadcast" */; 314 | compatibilityVersion = "Xcode 9.3"; 315 | developmentRegion = en; 316 | hasScannedForEncodings = 0; 317 | knownRegions = ( 318 | en, 319 | Base, 320 | ); 321 | mainGroup = 7188E61C2687B0FE007CFD78; 322 | packageReferences = ( 323 | 7188E65B26887DCD007CFD78 /* XCRemoteSwiftPackageReference "SwiftKeychainWrapper" */, 324 | 7188E6682688D7BA007CFD78 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, 325 | 719087CB26891586005B96CE /* XCRemoteSwiftPackageReference "twitter-text" */, 326 | 715AAE0826C923A1002BCEA1 /* XCRemoteSwiftPackageReference "Swifter" */, 327 | ); 328 | productRefGroup = 7188E6262687B0FE007CFD78 /* Products */; 329 | projectDirPath = ""; 330 | projectRoot = ""; 331 | targets = ( 332 | 7188E6242687B0FE007CFD78 /* Broadcast */, 333 | 711EF99026C959A700FD8A9F /* BroadcastUITests */, 334 | ); 335 | }; 336 | /* End PBXProject section */ 337 | 338 | /* Begin PBXResourcesBuildPhase section */ 339 | 711EF98F26C959A700FD8A9F /* Resources */ = { 340 | isa = PBXResourcesBuildPhase; 341 | buildActionMask = 2147483647; 342 | files = ( 343 | ); 344 | runOnlyForDeploymentPostprocessing = 0; 345 | }; 346 | 7188E6232687B0FE007CFD78 /* Resources */ = { 347 | isa = PBXResourcesBuildPhase; 348 | buildActionMask = 2147483647; 349 | files = ( 350 | 7188E6302687B0FF007CFD78 /* Preview Assets.xcassets in Resources */, 351 | 71BBAAEB268CF532004048A0 /* TwitterAPI-Info.plist in Resources */, 352 | 7188E62D2687B0FF007CFD78 /* Assets.xcassets in Resources */, 353 | 71A6A264278C73AD00BF2387 /* TwitterAPI-Info.example.plist in Resources */, 354 | ); 355 | runOnlyForDeploymentPostprocessing = 0; 356 | }; 357 | /* End PBXResourcesBuildPhase section */ 358 | 359 | /* Begin PBXShellScriptBuildPhase section */ 360 | 71B82911268F2A34002AEE72 /* ShellScript */ = { 361 | isa = PBXShellScriptBuildPhase; 362 | buildActionMask = 2147483647; 363 | files = ( 364 | ); 365 | inputFileListPaths = ( 366 | ); 367 | inputPaths = ( 368 | ); 369 | outputFileListPaths = ( 370 | ); 371 | outputPaths = ( 372 | ); 373 | runOnlyForDeploymentPostprocessing = 0; 374 | shellPath = /bin/sh; 375 | shellScript = "CONFIG_FILE_BASE_NAME=\"TwitterAPI-Info\"\nCONFIG_FILE_NAME=${CONFIG_FILE_BASE_NAME}.plist\nSAMPLE_CONFIG_FILE_NAME=${CONFIG_FILE_BASE_NAME}-Sample.plist\nCONFIG_FILE_PATH=$SRCROOT/$PRODUCT_NAME/$CONFIG_FILE_NAME\nSAMPLE_CONFIG_FILE_PATH=$SRCROOT/$PRODUCT_NAME/$SAMPLE_CONFIG_FILE_NAME\nif [ -f \"$CONFIG_FILE_PATH\" ]; then\n echo \"$CONFIG_FILE_PATH exists.\"\nelse\n echo \"$CONFIG_FILE_PATH does not exist, copying sample\"\n cp -v \"${SAMPLE_CONFIG_FILE_PATH}\" \"${CONFIG_FILE_PATH}\"\nfi\n"; 376 | }; 377 | /* End PBXShellScriptBuildPhase section */ 378 | 379 | /* Begin PBXSourcesBuildPhase section */ 380 | 711EF98D26C959A700FD8A9F /* Sources */ = { 381 | isa = PBXSourcesBuildPhase; 382 | buildActionMask = 2147483647; 383 | files = ( 384 | 711EF99426C959A700FD8A9F /* BroadcastUITests.swift in Sources */, 385 | ); 386 | runOnlyForDeploymentPostprocessing = 0; 387 | }; 388 | 7188E6212687B0FE007CFD78 /* Sources */ = { 389 | isa = PBXSourcesBuildPhase; 390 | buildActionMask = 2147483647; 391 | files = ( 392 | 7188E6612688A01F007CFD78 /* Font.extension.swift in Sources */, 393 | 7188E62B2687B0FE007CFD78 /* ContentView.swift in Sources */, 394 | 717041F526B7037300001360 /* TweetView.swift in Sources */, 395 | 7188E63B2687B19D007CFD78 /* Notification.extension.swift in Sources */, 396 | 717041F326B6FFEA00001360 /* RepliesListView.swift in Sources */, 397 | 7188E6292687B0FE007CFD78 /* BroadcastApp.swift in Sources */, 398 | 71B8290C268D0AC6002AEE72 /* TwitterClient.swift in Sources */, 399 | 7188E6632688A0FC007CFD78 /* WelcomeView.swift in Sources */, 400 | 7199AE7A26B96D0D001DEB46 /* NSRegularExpression+Convenience.swift in Sources */, 401 | 717041F726B703A600001360 /* TwitterClient+MockTweet.swift in Sources */, 402 | 7188E6472687B6C2007CFD78 /* SafariView.swift in Sources */, 403 | 7188E6492687C0E0007CFD78 /* BroadcastButtonStyle.swift in Sources */, 404 | 71800BF026905DB3009D11A1 /* EngagementCountersView.swift in Sources */, 405 | 7199AE7C26B97327001DEB46 /* UserView.swift in Sources */, 406 | 7188E6652688A436007CFD78 /* ThemeHelper.swift in Sources */, 407 | 7199AE7E26B973B2001DEB46 /* MentionBar.swift in Sources */, 408 | 71800BF226906721009D11A1 /* ActionBarView.swift in Sources */, 409 | 7188E64F2687CCD0007CFD78 /* PhotoPicker.swift in Sources */, 410 | 7101073626C810AC00A713A5 /* NullStateView.swift in Sources */, 411 | 7188E66C2688DB44007CFD78 /* UIApplication.extension.swift in Sources */, 412 | 71800BFD26999B1B009D11A1 /* DraftsModel.xcdatamodeld in Sources */, 413 | 7188E6532687D16C007CFD78 /* AttachmentThumbnail.swift in Sources */, 414 | 7188E64B2687C44D007CFD78 /* String.extension.swift in Sources */, 415 | 71800BFF26999BA6009D11A1 /* PersistanceController.swift in Sources */, 416 | 71B82910268F2195002AEE72 /* LastTweetReplyView.swift in Sources */, 417 | 71800BF426906BC0009D11A1 /* DraftsListView.swift in Sources */, 418 | 71E36FFB2689EED40078D956 /* ShakeModifier.swift in Sources */, 419 | 71800BF8269998BF009D11A1 /* UIImage.extension.swift in Sources */, 420 | 719087CF26891C7F005B96CE /* Array.extension.swift in Sources */, 421 | 7188E64D2687C6A1007CFD78 /* Binding.extension.swift in Sources */, 422 | 7199AE8026B9892E001DEB46 /* Debouncer.swift in Sources */, 423 | 711F3FFA268F50C800605C89 /* Animation.extension.swift in Sources */, 424 | 71AA4AC6268A032400B7B577 /* RemoteImage.swift in Sources */, 425 | 7188E6672688B99E007CFD78 /* VisualEffectView.swift in Sources */, 426 | 7188E65F26889147007CFD78 /* SignOutView.swift in Sources */, 427 | 71A1088A268B073B007E1FFB /* Haptics.swift in Sources */, 428 | 71BBAAE9268CF1BD004048A0 /* ComposerView.swift in Sources */, 429 | 715AAE0C26C9589A002BCEA1 /* TestUtils.swift in Sources */, 430 | ); 431 | runOnlyForDeploymentPostprocessing = 0; 432 | }; 433 | /* End PBXSourcesBuildPhase section */ 434 | 435 | /* Begin PBXTargetDependency section */ 436 | 711EF99726C959A700FD8A9F /* PBXTargetDependency */ = { 437 | isa = PBXTargetDependency; 438 | target = 7188E6242687B0FE007CFD78 /* Broadcast */; 439 | targetProxy = 711EF99626C959A700FD8A9F /* PBXContainerItemProxy */; 440 | }; 441 | /* End PBXTargetDependency section */ 442 | 443 | /* Begin XCBuildConfiguration section */ 444 | 711EF99826C959A700FD8A9F /* Debug */ = { 445 | isa = XCBuildConfiguration; 446 | buildSettings = { 447 | CODE_SIGN_STYLE = Automatic; 448 | DEVELOPMENT_TEAM = YC249PY26F; 449 | INFOPLIST_FILE = BroadcastUITests/Info.plist; 450 | LD_RUNPATH_SEARCH_PATHS = ( 451 | "$(inherited)", 452 | "@executable_path/Frameworks", 453 | "@loader_path/Frameworks", 454 | ); 455 | PRODUCT_BUNDLE_IDENTIFIER = me.daneden.BroadcastUITests; 456 | PRODUCT_NAME = "$(TARGET_NAME)"; 457 | SWIFT_VERSION = 5.0; 458 | TARGETED_DEVICE_FAMILY = "1,2"; 459 | TEST_TARGET_NAME = Broadcast; 460 | }; 461 | name = Debug; 462 | }; 463 | 711EF99926C959A700FD8A9F /* Release */ = { 464 | isa = XCBuildConfiguration; 465 | buildSettings = { 466 | CODE_SIGN_STYLE = Automatic; 467 | DEVELOPMENT_TEAM = YC249PY26F; 468 | INFOPLIST_FILE = BroadcastUITests/Info.plist; 469 | LD_RUNPATH_SEARCH_PATHS = ( 470 | "$(inherited)", 471 | "@executable_path/Frameworks", 472 | "@loader_path/Frameworks", 473 | ); 474 | PRODUCT_BUNDLE_IDENTIFIER = me.daneden.BroadcastUITests; 475 | PRODUCT_NAME = "$(TARGET_NAME)"; 476 | SWIFT_VERSION = 5.0; 477 | TARGETED_DEVICE_FAMILY = "1,2"; 478 | TEST_TARGET_NAME = Broadcast; 479 | }; 480 | name = Release; 481 | }; 482 | 7188E6322687B0FF007CFD78 /* Debug */ = { 483 | isa = XCBuildConfiguration; 484 | buildSettings = { 485 | ALWAYS_SEARCH_USER_PATHS = NO; 486 | CLANG_ANALYZER_NONNULL = YES; 487 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 488 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 489 | CLANG_CXX_LIBRARY = "libc++"; 490 | CLANG_ENABLE_MODULES = YES; 491 | CLANG_ENABLE_OBJC_ARC = YES; 492 | CLANG_ENABLE_OBJC_WEAK = YES; 493 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 494 | CLANG_WARN_BOOL_CONVERSION = YES; 495 | CLANG_WARN_COMMA = YES; 496 | CLANG_WARN_CONSTANT_CONVERSION = YES; 497 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 498 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 499 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 500 | CLANG_WARN_EMPTY_BODY = YES; 501 | CLANG_WARN_ENUM_CONVERSION = YES; 502 | CLANG_WARN_INFINITE_RECURSION = YES; 503 | CLANG_WARN_INT_CONVERSION = YES; 504 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 505 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 506 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 507 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 508 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 509 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 510 | CLANG_WARN_STRICT_PROTOTYPES = YES; 511 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 512 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 513 | CLANG_WARN_UNREACHABLE_CODE = YES; 514 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 515 | COPY_PHASE_STRIP = NO; 516 | DEBUG_INFORMATION_FORMAT = dwarf; 517 | ENABLE_STRICT_OBJC_MSGSEND = YES; 518 | ENABLE_TESTABILITY = YES; 519 | GCC_C_LANGUAGE_STANDARD = gnu11; 520 | GCC_DYNAMIC_NO_PIC = NO; 521 | GCC_NO_COMMON_BLOCKS = YES; 522 | GCC_OPTIMIZATION_LEVEL = 0; 523 | GCC_PREPROCESSOR_DEFINITIONS = ( 524 | "DEBUG=1", 525 | "$(inherited)", 526 | ); 527 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 528 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 529 | GCC_WARN_UNDECLARED_SELECTOR = YES; 530 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 531 | GCC_WARN_UNUSED_FUNCTION = YES; 532 | GCC_WARN_UNUSED_VARIABLE = YES; 533 | IPHONEOS_DEPLOYMENT_TARGET = 14.5; 534 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 535 | MTL_FAST_MATH = YES; 536 | ONLY_ACTIVE_ARCH = YES; 537 | SDKROOT = iphoneos; 538 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 539 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 540 | }; 541 | name = Debug; 542 | }; 543 | 7188E6332687B0FF007CFD78 /* Release */ = { 544 | isa = XCBuildConfiguration; 545 | buildSettings = { 546 | ALWAYS_SEARCH_USER_PATHS = NO; 547 | CLANG_ANALYZER_NONNULL = YES; 548 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 549 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 550 | CLANG_CXX_LIBRARY = "libc++"; 551 | CLANG_ENABLE_MODULES = YES; 552 | CLANG_ENABLE_OBJC_ARC = YES; 553 | CLANG_ENABLE_OBJC_WEAK = YES; 554 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 555 | CLANG_WARN_BOOL_CONVERSION = YES; 556 | CLANG_WARN_COMMA = YES; 557 | CLANG_WARN_CONSTANT_CONVERSION = YES; 558 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 559 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 560 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 561 | CLANG_WARN_EMPTY_BODY = YES; 562 | CLANG_WARN_ENUM_CONVERSION = YES; 563 | CLANG_WARN_INFINITE_RECURSION = YES; 564 | CLANG_WARN_INT_CONVERSION = YES; 565 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 566 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 567 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 568 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 569 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 570 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 571 | CLANG_WARN_STRICT_PROTOTYPES = YES; 572 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 573 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 574 | CLANG_WARN_UNREACHABLE_CODE = YES; 575 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 576 | COPY_PHASE_STRIP = NO; 577 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 578 | ENABLE_NS_ASSERTIONS = NO; 579 | ENABLE_STRICT_OBJC_MSGSEND = YES; 580 | GCC_C_LANGUAGE_STANDARD = gnu11; 581 | GCC_NO_COMMON_BLOCKS = YES; 582 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 583 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 584 | GCC_WARN_UNDECLARED_SELECTOR = YES; 585 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 586 | GCC_WARN_UNUSED_FUNCTION = YES; 587 | GCC_WARN_UNUSED_VARIABLE = YES; 588 | IPHONEOS_DEPLOYMENT_TARGET = 14.5; 589 | MTL_ENABLE_DEBUG_INFO = NO; 590 | MTL_FAST_MATH = YES; 591 | SDKROOT = iphoneos; 592 | SWIFT_COMPILATION_MODE = wholemodule; 593 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 594 | VALIDATE_PRODUCT = YES; 595 | }; 596 | name = Release; 597 | }; 598 | 7188E6352687B0FF007CFD78 /* Debug */ = { 599 | isa = XCBuildConfiguration; 600 | buildSettings = { 601 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 602 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 603 | CODE_SIGN_STYLE = Automatic; 604 | CURRENT_PROJECT_VERSION = 38; 605 | DEVELOPMENT_ASSET_PATHS = "\"Broadcast/Preview Content\""; 606 | DEVELOPMENT_TEAM = YC249PY26F; 607 | ENABLE_PREVIEWS = YES; 608 | INFOPLIST_FILE = Broadcast/Info.plist; 609 | IPHONEOS_DEPLOYMENT_TARGET = 14.0; 610 | LD_RUNPATH_SEARCH_PATHS = ( 611 | "$(inherited)", 612 | "@executable_path/Frameworks", 613 | ); 614 | MARKETING_VERSION = 1.0.1; 615 | PRODUCT_BUNDLE_IDENTIFIER = me.daneden.Broadcast; 616 | PRODUCT_NAME = "$(TARGET_NAME)"; 617 | SWIFT_VERSION = 5.0; 618 | TARGETED_DEVICE_FAMILY = "1,2"; 619 | }; 620 | name = Debug; 621 | }; 622 | 7188E6362687B0FF007CFD78 /* Release */ = { 623 | isa = XCBuildConfiguration; 624 | buildSettings = { 625 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 626 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 627 | CODE_SIGN_STYLE = Automatic; 628 | CURRENT_PROJECT_VERSION = 38; 629 | DEVELOPMENT_ASSET_PATHS = "\"Broadcast/Preview Content\""; 630 | DEVELOPMENT_TEAM = YC249PY26F; 631 | ENABLE_PREVIEWS = YES; 632 | INFOPLIST_FILE = Broadcast/Info.plist; 633 | IPHONEOS_DEPLOYMENT_TARGET = 14.0; 634 | LD_RUNPATH_SEARCH_PATHS = ( 635 | "$(inherited)", 636 | "@executable_path/Frameworks", 637 | ); 638 | MARKETING_VERSION = 1.0.1; 639 | PRODUCT_BUNDLE_IDENTIFIER = me.daneden.Broadcast; 640 | PRODUCT_NAME = "$(TARGET_NAME)"; 641 | SWIFT_VERSION = 5.0; 642 | TARGETED_DEVICE_FAMILY = "1,2"; 643 | }; 644 | name = Release; 645 | }; 646 | /* End XCBuildConfiguration section */ 647 | 648 | /* Begin XCConfigurationList section */ 649 | 711EF99A26C959A700FD8A9F /* Build configuration list for PBXNativeTarget "BroadcastUITests" */ = { 650 | isa = XCConfigurationList; 651 | buildConfigurations = ( 652 | 711EF99826C959A700FD8A9F /* Debug */, 653 | 711EF99926C959A700FD8A9F /* Release */, 654 | ); 655 | defaultConfigurationIsVisible = 0; 656 | defaultConfigurationName = Release; 657 | }; 658 | 7188E6202687B0FE007CFD78 /* Build configuration list for PBXProject "Broadcast" */ = { 659 | isa = XCConfigurationList; 660 | buildConfigurations = ( 661 | 7188E6322687B0FF007CFD78 /* Debug */, 662 | 7188E6332687B0FF007CFD78 /* Release */, 663 | ); 664 | defaultConfigurationIsVisible = 0; 665 | defaultConfigurationName = Release; 666 | }; 667 | 7188E6342687B0FF007CFD78 /* Build configuration list for PBXNativeTarget "Broadcast" */ = { 668 | isa = XCConfigurationList; 669 | buildConfigurations = ( 670 | 7188E6352687B0FF007CFD78 /* Debug */, 671 | 7188E6362687B0FF007CFD78 /* Release */, 672 | ); 673 | defaultConfigurationIsVisible = 0; 674 | defaultConfigurationName = Release; 675 | }; 676 | /* End XCConfigurationList section */ 677 | 678 | /* Begin XCRemoteSwiftPackageReference section */ 679 | 715AAE0826C923A1002BCEA1 /* XCRemoteSwiftPackageReference "Swifter" */ = { 680 | isa = XCRemoteSwiftPackageReference; 681 | repositoryURL = "https://github.com/daneden/Swifter"; 682 | requirement = { 683 | kind = upToNextMajorVersion; 684 | minimumVersion = 2.5.1; 685 | }; 686 | }; 687 | 7188E65B26887DCD007CFD78 /* XCRemoteSwiftPackageReference "SwiftKeychainWrapper" */ = { 688 | isa = XCRemoteSwiftPackageReference; 689 | repositoryURL = "https://github.com/jrendel/SwiftKeychainWrapper"; 690 | requirement = { 691 | kind = upToNextMajorVersion; 692 | minimumVersion = 4.0.1; 693 | }; 694 | }; 695 | 7188E6682688D7BA007CFD78 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */ = { 696 | isa = XCRemoteSwiftPackageReference; 697 | repositoryURL = "https://github.com/siteline/SwiftUI-Introspect"; 698 | requirement = { 699 | kind = upToNextMajorVersion; 700 | minimumVersion = 0.1.3; 701 | }; 702 | }; 703 | 719087CB26891586005B96CE /* XCRemoteSwiftPackageReference "twitter-text" */ = { 704 | isa = XCRemoteSwiftPackageReference; 705 | repositoryURL = "https://github.com/nysander/twitter-text.git"; 706 | requirement = { 707 | kind = upToNextMajorVersion; 708 | minimumVersion = 0.0.1; 709 | }; 710 | }; 711 | /* End XCRemoteSwiftPackageReference section */ 712 | 713 | /* Begin XCSwiftPackageProductDependency section */ 714 | 715AAE0926C923A1002BCEA1 /* Swifter */ = { 715 | isa = XCSwiftPackageProductDependency; 716 | package = 715AAE0826C923A1002BCEA1 /* XCRemoteSwiftPackageReference "Swifter" */; 717 | productName = Swifter; 718 | }; 719 | 7188E65C26887DCD007CFD78 /* SwiftKeychainWrapper */ = { 720 | isa = XCSwiftPackageProductDependency; 721 | package = 7188E65B26887DCD007CFD78 /* XCRemoteSwiftPackageReference "SwiftKeychainWrapper" */; 722 | productName = SwiftKeychainWrapper; 723 | }; 724 | 7188E6692688D7BA007CFD78 /* Introspect */ = { 725 | isa = XCSwiftPackageProductDependency; 726 | package = 7188E6682688D7BA007CFD78 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */; 727 | productName = Introspect; 728 | }; 729 | 719087CC26891586005B96CE /* TwitterText */ = { 730 | isa = XCSwiftPackageProductDependency; 731 | package = 719087CB26891586005B96CE /* XCRemoteSwiftPackageReference "twitter-text" */; 732 | productName = TwitterText; 733 | }; 734 | /* End XCSwiftPackageProductDependency section */ 735 | 736 | /* Begin XCVersionGroup section */ 737 | 71800BFB26999B1B009D11A1 /* DraftsModel.xcdatamodeld */ = { 738 | isa = XCVersionGroup; 739 | children = ( 740 | 71800BFC26999B1B009D11A1 /* DraftsModel.xcdatamodel */, 741 | ); 742 | currentVersion = 71800BFC26999B1B009D11A1 /* DraftsModel.xcdatamodel */; 743 | path = DraftsModel.xcdatamodeld; 744 | sourceTree = ""; 745 | versionGroupType = wrapper.xcdatamodel; 746 | }; 747 | /* End XCVersionGroup section */ 748 | }; 749 | rootObject = 7188E61D2687B0FE007CFD78 /* Project object */; 750 | } 751 | --------------------------------------------------------------------------------