├── V2EX ├── Assets.xcassets │ ├── Contents.json │ └── AppIcon.appiconset │ │ ├── Icon-76.png │ │ ├── Icon-1024.png │ │ ├── Icon-60@2x.png │ │ ├── Icon-60@3x.png │ │ ├── Icon-76@2x.png │ │ ├── Icon-Small.png │ │ ├── Icon-83.5@2x.png │ │ ├── Icon-Small-20.png │ │ ├── Icon-Small@2x.png │ │ ├── Icon-Small@3x.png │ │ ├── Icon-Small-20@2x.png │ │ ├── Icon-Small-20@3x.png │ │ ├── Icon-Small-40@2x.png │ │ └── Contents.json ├── Classes │ ├── Models │ │ ├── Section.swift │ │ ├── Comment.swift │ │ ├── Node.swift │ │ ├── Reply.swift │ │ ├── Session.swift │ │ ├── User.swift │ │ └── Topic.swift │ ├── Views │ │ ├── UIImageView.swift │ │ ├── UIView.swift │ │ ├── Topic │ │ │ ├── TopicBodyCell.swift │ │ │ ├── TopicCommentCell.swift │ │ │ ├── TopicNameCell.swift │ │ │ ├── TopicReplyCell.swift │ │ │ └── TopicWebCell.swift │ │ ├── ActivityIndicatorView.swift │ │ ├── NoContentView.swift │ │ ├── NetworkErrorView.swift │ │ ├── Topics │ │ │ ├── TopicsNodesView.swift │ │ │ ├── TopicsNodeButton.swift │ │ │ └── TopicsCell.swift │ │ ├── SignIn │ │ │ ├── SignInPasswordCell.swift │ │ │ ├── SignInUsernameCell.swift │ │ │ └── SignInCaptchaCell.swift │ │ └── User │ │ │ └── UserHeaderView.swift │ └── Controllers │ │ ├── ViewController.swift │ │ ├── ComposeController.swift │ │ ├── WebViewController.swift │ │ ├── MoreController.swift │ │ ├── NodesController.swift │ │ ├── SignInController.swift │ │ ├── UserController.swift │ │ ├── TopicsController.swift │ │ └── TopicController.swift ├── AppDelegate.swift ├── Info.plist └── zh-Hans.lproj │ └── LaunchScreen.storyboard ├── V2EX.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist ├── xcuserdata │ └── swordray.xcuserdatad │ │ └── xcschemes │ │ └── xcschememanagement.plist ├── xcshareddata │ └── xcschemes │ │ └── V2EX.xcscheme └── project.pbxproj ├── README.md ├── V2EX.xcworkspace ├── contents.xcworkspacedata └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── Podfile ├── LICENSE ├── .gitignore ├── .swiftlint.yml └── Podfile.lock /V2EX/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /V2EX/Assets.xcassets/AppIcon.appiconset/Icon-76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swordray/v2ex-ios/HEAD/V2EX/Assets.xcassets/AppIcon.appiconset/Icon-76.png -------------------------------------------------------------------------------- /V2EX/Assets.xcassets/AppIcon.appiconset/Icon-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swordray/v2ex-ios/HEAD/V2EX/Assets.xcassets/AppIcon.appiconset/Icon-1024.png -------------------------------------------------------------------------------- /V2EX/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swordray/v2ex-ios/HEAD/V2EX/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png -------------------------------------------------------------------------------- /V2EX/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swordray/v2ex-ios/HEAD/V2EX/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png -------------------------------------------------------------------------------- /V2EX/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swordray/v2ex-ios/HEAD/V2EX/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png -------------------------------------------------------------------------------- /V2EX/Assets.xcassets/AppIcon.appiconset/Icon-Small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swordray/v2ex-ios/HEAD/V2EX/Assets.xcassets/AppIcon.appiconset/Icon-Small.png -------------------------------------------------------------------------------- /V2EX/Assets.xcassets/AppIcon.appiconset/Icon-83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swordray/v2ex-ios/HEAD/V2EX/Assets.xcassets/AppIcon.appiconset/Icon-83.5@2x.png -------------------------------------------------------------------------------- /V2EX/Assets.xcassets/AppIcon.appiconset/Icon-Small-20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swordray/v2ex-ios/HEAD/V2EX/Assets.xcassets/AppIcon.appiconset/Icon-Small-20.png -------------------------------------------------------------------------------- /V2EX/Assets.xcassets/AppIcon.appiconset/Icon-Small@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swordray/v2ex-ios/HEAD/V2EX/Assets.xcassets/AppIcon.appiconset/Icon-Small@2x.png -------------------------------------------------------------------------------- /V2EX/Assets.xcassets/AppIcon.appiconset/Icon-Small@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swordray/v2ex-ios/HEAD/V2EX/Assets.xcassets/AppIcon.appiconset/Icon-Small@3x.png -------------------------------------------------------------------------------- /V2EX/Assets.xcassets/AppIcon.appiconset/Icon-Small-20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swordray/v2ex-ios/HEAD/V2EX/Assets.xcassets/AppIcon.appiconset/Icon-Small-20@2x.png -------------------------------------------------------------------------------- /V2EX/Assets.xcassets/AppIcon.appiconset/Icon-Small-20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swordray/v2ex-ios/HEAD/V2EX/Assets.xcassets/AppIcon.appiconset/Icon-Small-20@3x.png -------------------------------------------------------------------------------- /V2EX/Assets.xcassets/AppIcon.appiconset/Icon-Small-40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swordray/v2ex-ios/HEAD/V2EX/Assets.xcassets/AppIcon.appiconset/Icon-Small-40@2x.png -------------------------------------------------------------------------------- /V2EX.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # v2ex-ios 2 | 3 | A V2EX iOS App. 4 | 5 | ## Sponsors 6 | 7 | * [BaiLu ShuYuan](https://bailushuyuan.org) 8 | 9 | ## License 10 | 11 | Copyright © 2019 Jianqiu Xiao under The [MIT License](http://opensource.org/licenses/MIT). 12 | -------------------------------------------------------------------------------- /V2EX.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /V2EX.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /V2EX/Classes/Models/Section.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Section.swift 3 | // V2EX 4 | // 5 | // Created by Jianqiu Xiao on 4/19/19. 6 | // Copyright © 2019 Jianqiu Xiao. All rights reserved. 7 | // 8 | 9 | import Ladybug 10 | 11 | struct Section: JSONCodable { 12 | 13 | var name: String? 14 | var nodes: [Node] 15 | } 16 | -------------------------------------------------------------------------------- /V2EX.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /V2EX/Classes/Models/Comment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Comment.swift 3 | // V2EX 4 | // 5 | // Created by Jianqiu Xiao on 4/17/19. 6 | // Copyright © 2019 Jianqiu Xiao. All rights reserved. 7 | // 8 | 9 | import Ladybug 10 | 11 | struct Comment: JSONCodable { 12 | 13 | var bodyHTML: String? 14 | var createdAt: String? 15 | var index: Int? 16 | } 17 | -------------------------------------------------------------------------------- /V2EX/Classes/Models/Node.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Node.swift 3 | // V2EX 4 | // 5 | // Created by Jianqiu Xiao on 4/8/19. 6 | // Copyright © 2019 Jianqiu Xiao. All rights reserved. 7 | // 8 | 9 | import Ladybug 10 | 11 | struct Node: JSONCodable { 12 | 13 | var name: String? 14 | var code: String? 15 | 16 | static var all = Node(name: "全部", code: "all") 17 | } 18 | -------------------------------------------------------------------------------- /V2EX/Classes/Models/Reply.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Reply.swift 3 | // V2EX 4 | // 5 | // Created by Jianqiu Xiao on 4/10/19. 6 | // Copyright © 2019 Jianqiu Xiao. All rights reserved. 7 | // 8 | 9 | import Ladybug 10 | 11 | struct Reply: JSONCodable { 12 | 13 | var bodyHTML: String? 14 | var createdAt: String? 15 | var user: User? 16 | var index: Int? 17 | } 18 | -------------------------------------------------------------------------------- /V2EX.xcodeproj/xcuserdata/swordray.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | V2EX.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 17 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /V2EX/Classes/Models/Session.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Session.swift 3 | // V2EX 4 | // 5 | // Created by Jianqiu Xiao on 4/17/19. 6 | // Copyright © 2019 Jianqiu Xiao. All rights reserved. 7 | // 8 | 9 | import Ladybug 10 | 11 | struct Session: JSONCodable { 12 | 13 | var usernameKey: String 14 | var passwordKey: String 15 | var captchaKey: String 16 | var once: String 17 | var username: String? 18 | var password: String? 19 | var captcha: String? 20 | } 21 | -------------------------------------------------------------------------------- /Podfile: -------------------------------------------------------------------------------- 1 | platform :ios, '13.0' 2 | 3 | target 'V2EX' do 4 | use_frameworks! 5 | 6 | pod 'Alamofire', '~> 5.0.0-rc.2' 7 | pod 'AlamofireImage', '~> 4.0.0-beta.5' 8 | pod 'CrossroadRegex' 9 | pod 'Firebase/Core' 10 | pod 'JGProgressHUD' 11 | pod 'JXWebViewController' 12 | pod 'Kanna' 13 | pod 'KeychainAccess' 14 | pod 'Ladybug' 15 | pod 'SnapKit' 16 | pod 'SwiftDate' 17 | pod 'SwiftLint' 18 | pod 'TUSafariActivity' 19 | pod 'UIButtonSetBackgroundColorForState' 20 | end 21 | -------------------------------------------------------------------------------- /V2EX/Classes/Views/UIImageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIImageView.swift 3 | // V2EX 4 | // 5 | // Created by Jianqiu Xiao on 6/4/19. 6 | // Copyright © 2019 Jianqiu Xiao. All rights reserved. 7 | // 8 | 9 | import AlamofireImage 10 | 11 | extension UIImageView { 12 | 13 | internal func setImage(withURL url: URL?) { 14 | if let url = url { 15 | af_setImage(withURL: url, placeholderImage: UIImage()) 16 | } else { 17 | image = nil 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /V2EX/Classes/Views/UIView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIView.swift 3 | // V2EX 4 | // 5 | // Created by Jianqiu Xiao on 4/10/19. 6 | // Copyright © 2019 Jianqiu Xiao. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension UIResponder { 12 | 13 | internal func next(of type: T.Type) -> T? { 14 | return next as? T ?? next?.next(of: type) 15 | } 16 | } 17 | 18 | extension UIView { 19 | 20 | internal var viewController: ViewController? { 21 | return next(of: ViewController.self) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /V2EX/Classes/Models/User.swift: -------------------------------------------------------------------------------- 1 | // 2 | // User.swift 3 | // V2EX 4 | // 5 | // Created by Jianqiu Xiao on 4/8/19. 6 | // Copyright © 2019 Jianqiu Xiao. All rights reserved. 7 | // 8 | 9 | import Ladybug 10 | 11 | struct User: JSONCodable { 12 | 13 | var id: Int? 14 | var name: String? 15 | var avatar: URL? 16 | var isBlocked: Bool? 17 | var token: String? 18 | var once: String? 19 | var createdAt: Date? 20 | var topics: [Topic]? 21 | 22 | static let transformersByPropertyKey: [PropertyKey: JSONTransformer] = [ 23 | "createdAt": "createdAt" <- format("yyyy-MM-dd HH:mm:ss ZZZZZ"), 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /V2EX/Classes/Models/Topic.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Topic.swift 3 | // V2EX 4 | // 5 | // Created by Jianqiu Xiao on 4/8/19. 6 | // Copyright © 2019 Jianqiu Xiao. All rights reserved. 7 | // 8 | 9 | import Ladybug 10 | 11 | struct Topic: JSONCodable { 12 | 13 | var id: Int? 14 | var name: String? 15 | var bodyHTML: String? 16 | var clicksCount: Int? 17 | var favoritesCount: Int? 18 | var repliesCount: Int? 19 | var repliesNextPage: Int? 20 | var repliedAt: String? 21 | var createdAt: String? 22 | var isSticky: Bool? 23 | var isFavorite: Bool? 24 | var favoriteToken: String? 25 | var once: Int? 26 | var user: User? 27 | var node: Node? 28 | var comments: [Comment]? 29 | } 30 | -------------------------------------------------------------------------------- /V2EX/Classes/Views/Topic/TopicBodyCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BodyCell.swift 3 | // V2EX 4 | // 5 | // Created by Jianqiu Xiao on 4/10/19. 6 | // Copyright © 2019 Jianqiu Xiao. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class TopicBodyCell: TopicWebCell { 12 | 13 | public var topic: Topic? { didSet { didSetTopic() } } 14 | 15 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 16 | super.init(style: style, reuseIdentifier: reuseIdentifier) 17 | 18 | contentView.addSubview(webView) 19 | webView.snp.makeConstraints { $0.edges.equalTo(contentView.layoutMarginsGuide).priority(999) } 20 | } 21 | 22 | private func didSetTopic() { 23 | bodyHTML = topic?.bodyHTML 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /V2EX/Classes/Views/ActivityIndicatorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ActivityIndicatorView.swift 3 | // V2EX 4 | // 5 | // Created by Jianqiu Xiao on 4/11/19. 6 | // Copyright © 2019 Jianqiu Xiao. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class ActivityIndicatorView: UIActivityIndicatorView { 12 | 13 | init() { 14 | super.init(style: .medium) 15 | } 16 | 17 | @available(*, unavailable) 18 | required init(coder: NSCoder) { 19 | fatalError("init(coder:) has not been implemented") 20 | } 21 | 22 | override func didMoveToSuperview() { 23 | super.didMoveToSuperview() 24 | 25 | if let superview = superview { 26 | snp.makeConstraints { $0.center.equalTo(superview.safeAreaLayoutGuide) } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /V2EX/Classes/Views/NoContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NotFoundView.swift 3 | // V2EX 4 | // 5 | // Created by Jianqiu Xiao on 4/11/19. 6 | // Copyright © 2019 Jianqiu Xiao. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class NoContentView: UIStackView { 12 | 13 | public var textLabel: UILabel! 14 | 15 | override init(frame: CGRect) { 16 | super.init(frame: frame) 17 | 18 | alignment = .center 19 | 20 | isHidden = true 21 | 22 | textLabel = UILabel() 23 | textLabel.font = .preferredFont(forTextStyle: .title1) 24 | textLabel.textColor = .secondaryLabel 25 | addArrangedSubview(textLabel) 26 | } 27 | 28 | @available(*, unavailable) 29 | required init(coder: NSCoder) { 30 | fatalError("init(coder:) has not been implemented") 31 | } 32 | 33 | override func didMoveToSuperview() { 34 | super.didMoveToSuperview() 35 | 36 | if let superview = superview { 37 | snp.makeConstraints { $0.center.equalTo(superview.safeAreaLayoutGuide) } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Jianqiu Xiao 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 | -------------------------------------------------------------------------------- /V2EX/Classes/Views/NetworkErrorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkErrorView.swift 3 | // V2EX 4 | // 5 | // Created by Jianqiu Xiao on 4/11/19. 6 | // Copyright © 2019 Jianqiu Xiao. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class NetworkErrorView: UIView { 12 | 13 | override init(frame: CGRect) { 14 | super.init(frame: frame) 15 | 16 | isHidden = true 17 | 18 | let label = UILabel() 19 | label.backgroundColor = .systemGray 20 | label.clipsToBounds = true 21 | label.font = .boldSystemFont(ofSize: 17) 22 | label.layer.cornerRadius = 10 23 | label.text = "!" 24 | label.textAlignment = .center 25 | label.textColor = .white 26 | addSubview(label) 27 | label.snp.makeConstraints { make in 28 | make.center.equalToSuperview() 29 | make.size.equalTo(20) 30 | } 31 | } 32 | 33 | @available(*, unavailable) 34 | required init?(coder aDecoder: NSCoder) { 35 | fatalError("init(coder:) has not been implemented") 36 | } 37 | 38 | override func didMoveToSuperview() { 39 | super.didMoveToSuperview() 40 | 41 | if let superview = superview { 42 | snp.makeConstraints { $0.edges.equalTo(superview.safeAreaLayoutGuide) } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /V2EX/Classes/Views/Topic/TopicCommentCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TopicCommentCell.swift 3 | // V2EX 4 | // 5 | // Created by Jianqiu Xiao on 4/19/19. 6 | // Copyright © 2019 Jianqiu Xiao. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class TopicCommentCell: TopicWebCell { 12 | 13 | public var comment: Comment? { didSet { didSetComment() } } 14 | private var createdAtLabel: UILabel! 15 | 16 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 17 | super.init(style: style, reuseIdentifier: reuseIdentifier) 18 | 19 | let stackView = UIStackView() 20 | stackView.axis = .vertical 21 | stackView.spacing = 8 22 | contentView.addSubview(stackView) 23 | stackView.snp.makeConstraints { $0.margins.equalToSuperview().priority(999) } 24 | 25 | createdAtLabel = UILabel() 26 | createdAtLabel.font = .preferredFont(forTextStyle: .subheadline) 27 | createdAtLabel.numberOfLines = 0 28 | createdAtLabel.textColor = .secondaryLabel 29 | stackView.addArrangedSubview(createdAtLabel) 30 | 31 | stackView.addArrangedSubview(webView) 32 | } 33 | 34 | private func didSetComment() { 35 | bodyHTML = comment?.bodyHTML 36 | createdAtLabel.text = "第 \((comment?.index ?? 0) + 1) 条附言 · \(comment?.createdAt ?? "")" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /V2EX/Classes/Views/Topics/TopicsNodesView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TopicsNodesView.swift 3 | // V2EX 4 | // 5 | // Created by Jianqiu Xiao on 4/22/19. 6 | // Copyright © 2019 Jianqiu Xiao. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class TopicsNodesView: UIScrollView { 12 | 13 | public var isEnabled = true { didSet { didSetEnabled() } } 14 | public var nodes: [Node]? { didSet { didSetNodes() } } 15 | private var stackView: UIStackView! 16 | 17 | override init(frame: CGRect) { 18 | super.init(frame: frame) 19 | 20 | alwaysBounceHorizontal = true 21 | 22 | showsHorizontalScrollIndicator = false 23 | 24 | stackView = UIStackView() 25 | stackView.alignment = .center 26 | stackView.spacing = 8 27 | addSubview(stackView) 28 | stackView.snp.makeConstraints { $0.leading.trailing.centerY.equalToSuperview() } 29 | 30 | let topicsNodeButton = TopicsNodeButton() 31 | topicsNodeButton.isSelected = true 32 | topicsNodeButton.node = .all 33 | stackView.addArrangedSubview(topicsNodeButton) 34 | } 35 | 36 | @available(*, unavailable) 37 | required init?(coder aDecoder: NSCoder) { 38 | fatalError("init(coder:) has not been implemented") 39 | } 40 | 41 | private func didSetEnabled() { 42 | stackView.arrangedSubviews.forEach { ($0 as? TopicsNodeButton)?.isEnabled = isEnabled } 43 | } 44 | 45 | private func didSetNodes() { 46 | nodes?.forEach { node in 47 | let topicsNodeButton = TopicsNodeButton() 48 | topicsNodeButton.node = node 49 | stackView.addArrangedSubview(topicsNodeButton) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /V2EX/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // V2EX 4 | // 5 | // Created by Jianqiu Xiao on 4/7/19. 6 | // Copyright © 2019 Jianqiu Xiao. All rights reserved. 7 | // 8 | 9 | import Firebase 10 | import Regex 11 | import SnapKit 12 | import SwiftDate 13 | import UIButtonSetBackgroundColorForState 14 | 15 | @UIApplicationMain 16 | class AppDelegate: UIResponder, UIApplicationDelegate { 17 | 18 | var window: UIWindow? 19 | 20 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 21 | 22 | FirebaseApp.configure() 23 | 24 | SwiftDate.defaultRegion = Region(calendar: Calendars.gregorian, zone: Zones.asiaShanghai, locale: Locales.chinese) 25 | 26 | let tabBarController = UITabBarController() 27 | tabBarController.viewControllers = [ 28 | (TopicsController(), UITabBarItem(tabBarSystemItem: .featured, tag: 0)), 29 | (NodesController(), UITabBarItem(tabBarSystemItem: .mostViewed, tag: 1)), 30 | (MoreController(), UITabBarItem(tabBarSystemItem: .more, tag: 2)), 31 | ].map { viewController, tabBarItem in 32 | let navigationController = UINavigationController(rootViewController: viewController) 33 | navigationController.navigationBar.prefersLargeTitles = true 34 | navigationController.tabBarItem = tabBarItem 35 | return navigationController 36 | } 37 | tabBarController.viewControllers?[1].tabBarItem.setValue("节点", forKey: "internalTitle") 38 | 39 | window = UIWindow(frame: UIScreen.main.bounds) 40 | window?.rootViewController = tabBarController 41 | window?.makeKeyAndVisible() 42 | 43 | return true 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /V2EX/Classes/Views/SignIn/SignInPasswordCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SignInPasswordCell.swift 3 | // V2EX 4 | // 5 | // Created by Jianqiu Xiao on 4/22/19. 6 | // Copyright © 2019 Jianqiu Xiao. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class SignInPasswordCell: UITableViewCell { 12 | 13 | public var passwordField: UITextField! 14 | 15 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 16 | super.init(style: style, reuseIdentifier: reuseIdentifier) 17 | 18 | selectionStyle = .none 19 | 20 | textLabel?.text = " " 21 | 22 | passwordField = UITextField() 23 | passwordField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged) 24 | passwordField.clearButtonMode = .whileEditing 25 | passwordField.delegate = self 26 | passwordField.font = .preferredFont(forTextStyle: .body) 27 | passwordField.isSecureTextEntry = true 28 | passwordField.placeholder = "密码" 29 | passwordField.returnKeyType = .next 30 | passwordField.textContentType = .password 31 | contentView.addSubview(passwordField) 32 | passwordField.snp.makeConstraints { $0.edges.equalTo(textLabel ?? .init()) } 33 | } 34 | 35 | @available(*, unavailable) 36 | required init?(coder aDecoder: NSCoder) { 37 | fatalError("init(coder:) has not been implemented") 38 | } 39 | 40 | @objc 41 | private func textFieldDidChange(_ textField: UITextField) { 42 | (viewController as? SignInController)?.session?.password = textField.text 43 | } 44 | } 45 | 46 | extension SignInPasswordCell: UITextFieldDelegate { 47 | 48 | func textFieldShouldReturn(_ textField: UITextField) -> Bool { 49 | (next(of: UITableView.self)?.cellForRow(at: IndexPath(row: 0, section: 1)) as? SignInCaptchaCell)?.captchaField.becomeFirstResponder() 50 | return false 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /V2EX/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | V2EX 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | $(MARKETING_VERSION) 21 | CFBundleVersion 22 | $(CURRENT_PROJECT_VERSION) 23 | ITSAppUsesNonExemptEncryption 24 | 25 | LSRequiresIPhoneOS 26 | 27 | NSAppTransportSecurity 28 | 29 | NSAllowsArbitraryLoadsInWebContent 30 | 31 | 32 | NSFaceIDUsageDescription 33 | 34 | UILaunchStoryboardName 35 | LaunchScreen 36 | UIRequiredDeviceCapabilities 37 | 38 | armv7 39 | 40 | UISupportedInterfaceOrientations 41 | 42 | UIInterfaceOrientationPortrait 43 | UIInterfaceOrientationLandscapeLeft 44 | UIInterfaceOrientationLandscapeRight 45 | 46 | UISupportedInterfaceOrientations~ipad 47 | 48 | UIInterfaceOrientationPortrait 49 | UIInterfaceOrientationPortraitUpsideDown 50 | UIInterfaceOrientationLandscapeLeft 51 | UIInterfaceOrientationLandscapeRight 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /V2EX/Classes/Views/SignIn/SignInUsernameCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SignInUsernameCell.swift 3 | // V2EX 4 | // 5 | // Created by Jianqiu Xiao on 4/22/19. 6 | // Copyright © 2019 Jianqiu Xiao. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class SignInUsernameCell: UITableViewCell { 12 | 13 | public var usernameField: UITextField! 14 | 15 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 16 | super.init(style: style, reuseIdentifier: reuseIdentifier) 17 | 18 | selectionStyle = .none 19 | 20 | textLabel?.text = " " 21 | 22 | usernameField = UITextField() 23 | usernameField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged) 24 | usernameField.autocapitalizationType = .none 25 | usernameField.autocorrectionType = .no 26 | usernameField.clearButtonMode = .whileEditing 27 | usernameField.delegate = self 28 | usernameField.font = .preferredFont(forTextStyle: .body) 29 | usernameField.placeholder = "帐号" 30 | usernameField.returnKeyType = .next 31 | usernameField.textContentType = .username 32 | contentView.addSubview(usernameField) 33 | usernameField.snp.makeConstraints { $0.edges.equalTo(textLabel ?? .init()) } 34 | } 35 | 36 | @available(*, unavailable) 37 | required init?(coder aDecoder: NSCoder) { 38 | fatalError("init(coder:) has not been implemented") 39 | } 40 | 41 | @objc 42 | private func textFieldDidChange(_ textField: UITextField) { 43 | (viewController as? SignInController)?.session?.username = textField.text 44 | } 45 | } 46 | 47 | extension SignInUsernameCell: UITextFieldDelegate { 48 | 49 | func textFieldShouldReturn(_ textField: UITextField) -> Bool { 50 | (next(of: UITableView.self)?.cellForRow(at: IndexPath(row: 1, section: 0)) as? SignInPasswordCell)?.passwordField.becomeFirstResponder() 51 | return false 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## Build generated 6 | build/ 7 | DerivedData/ 8 | 9 | ## Various settings 10 | *.pbxuser 11 | !default.pbxuser 12 | *.mode1v3 13 | !default.mode1v3 14 | *.mode2v3 15 | !default.mode2v3 16 | *.perspectivev3 17 | !default.perspectivev3 18 | xcuserdata/ 19 | 20 | ## Other 21 | *.moved-aside 22 | *.xccheckout 23 | *.xcscmblueprint 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | *.ipa 28 | *.dSYM.zip 29 | *.dSYM 30 | 31 | ## Playgrounds 32 | timeline.xctimeline 33 | playground.xcworkspace 34 | 35 | # Swift Package Manager 36 | # 37 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 38 | # Packages/ 39 | # Package.pins 40 | # Package.resolved 41 | .build/ 42 | 43 | # CocoaPods 44 | # 45 | # We recommend against adding the Pods directory to your .gitignore. However 46 | # you should judge for yourself, the pros and cons are mentioned at: 47 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 48 | 49 | Pods/ 50 | 51 | # Add this line if you want to avoid checking in source code from the Xcode workspace 52 | # *.xcworkspace 53 | 54 | # Carthage 55 | # 56 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 57 | # Carthage/Checkouts 58 | 59 | Carthage/Build 60 | 61 | # fastlane 62 | # 63 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 64 | # screenshots whenever they are needed. 65 | # For more information about the recommended setup visit: 66 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 67 | 68 | fastlane/report.xml 69 | fastlane/Preview.html 70 | fastlane/screenshots/**/*.png 71 | fastlane/test_output 72 | 73 | # Code Injection 74 | # 75 | # After new code Injection tools there's a generated folder /iOSInjectionProject 76 | # https://github.com/johnno1962/injectionforxcode 77 | 78 | iOSInjectionProject/ 79 | 80 | GoogleService-Info.plist 81 | -------------------------------------------------------------------------------- /V2EX/Classes/Views/Topics/TopicsNodeButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TopicsNodeButton.swift 3 | // V2EX 4 | // 5 | // Created by Jianqiu Xiao on 4/22/19. 6 | // Copyright © 2019 Jianqiu Xiao. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class TopicsNodeButton: UIButton { 12 | 13 | public var node: Node! { didSet { didSetNode() } } 14 | 15 | override init(frame: CGRect) { 16 | super.init(frame: frame) 17 | 18 | addTarget(self, action: #selector(selectNode), for: .touchUpInside) 19 | 20 | clipsToBounds = true 21 | 22 | contentEdgeInsets = UIEdgeInsets(top: 5, left: 8, bottom: 5, right: 8) 23 | 24 | layer.cornerRadius = 5 25 | 26 | setBackgroundColor(.clear, for: .normal) 27 | 28 | setTitleColor(.white, for: .selected) 29 | setTitleColor(.white, for: [.selected, .highlighted]) 30 | setTitleColor(.white, for: [.selected, .disabled]) 31 | 32 | titleLabel?.font = .systemFont(ofSize: 15) 33 | 34 | tintColorDidChange() 35 | } 36 | 37 | @available(*, unavailable) 38 | required init?(coder aDecoder: NSCoder) { 39 | fatalError("init(coder:) has not been implemented") 40 | } 41 | 42 | override func tintColorDidChange() { 43 | super.tintColorDidChange() 44 | 45 | setBackgroundColor(tintColor, for: .selected) 46 | setBackgroundColor(tintColor, for: [.selected, .highlighted]) 47 | setBackgroundColor(tintColor.withAlphaComponent(0.16), for: .highlighted) 48 | setBackgroundColor(tintColor.withAlphaComponent(0.5), for: [.selected, .disabled]) 49 | 50 | setTitleColor(tintColor, for: .normal) 51 | setTitleColor(tintColor.withAlphaComponent(0.5), for: .disabled) 52 | } 53 | 54 | private func didSetNode() { 55 | setTitle(node.name, for: .normal) 56 | } 57 | 58 | @objc 59 | private func selectNode() { 60 | if isSelected { return } 61 | 62 | (superview as? UIStackView)?.arrangedSubviews.forEach { ($0 as? TopicsNodeButton)?.isSelected = $0 == self } 63 | 64 | (viewController as? TopicsController)?.node = node 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | excluded: 2 | - Pods 3 | 4 | disabled_rules: 5 | - cyclomatic_complexity 6 | - file_length 7 | - function_body_length 8 | - line_length 9 | - nesting 10 | - type_body_length 11 | 12 | opt_in_rules: 13 | - anyobject_protocol 14 | - array_init 15 | - attributes 16 | - closure_end_indentation 17 | - closure_spacing 18 | - collection_alignment 19 | - contains_over_filter_count 20 | - contains_over_filter_is_empty 21 | - contains_over_first_not_nil 22 | - convenience_type 23 | - discouraged_object_literal 24 | - empty_collection_literal 25 | - empty_xctest_method 26 | - explicit_init 27 | - extension_access_modifier 28 | - fallthrough 29 | - fatal_error_message 30 | - file_name 31 | - first_where 32 | - force_unwrapping 33 | - function_default_parameter_at_end 34 | - identical_operands 35 | - implicit_return 36 | - joined_default_parameter 37 | - last_where 38 | - legacy_multiple 39 | - legacy_random 40 | - let_var_whitespace 41 | - literal_expression_end_indentation 42 | - modifier_order 43 | - multiline_arguments 44 | - multiline_function_chains 45 | - multiline_parameters 46 | - nimble_operator 47 | - no_extension_access_modifier 48 | - number_separator 49 | - operator_usage_whitespace 50 | - overridden_super_call 51 | - override_in_extension 52 | - pattern_matching_keywords 53 | - prefixed_toplevel_constant 54 | - private_action 55 | - private_outlet 56 | - prohibited_interface_builder 57 | - quick_discouraged_call 58 | - quick_discouraged_focused_test 59 | - quick_discouraged_pending_test 60 | - reduce_into 61 | - redundant_type_annotation 62 | - required_enum_case 63 | - single_test_class 64 | - sorted_first_last 65 | - sorted_imports 66 | - static_operator 67 | - strict_fileprivate 68 | - strong_iboutlet 69 | - switch_case_on_newline 70 | - trailing_closure 71 | - toggle_bool 72 | - unavailable_function 73 | - unneeded_parentheses_in_closure_argument 74 | - unowned_variable_capture 75 | - untyped_error_in_catch 76 | - unused_import 77 | - unused_private_declaration 78 | - vertical_parameter_alignment_on_call 79 | - vertical_whitespace_between_cases 80 | - vertical_whitespace_closing_braces 81 | - xct_specific_matcher 82 | - yoda_condition 83 | 84 | identifier_name: 85 | excluded: 86 | - id 87 | 88 | trailing_comma: 89 | mandatory_comma: true 90 | -------------------------------------------------------------------------------- /V2EX/Classes/Views/User/UserHeaderView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserHeaderView.swift 3 | // V2EX 4 | // 5 | // Created by Jianqiu Xiao on 4/16/19. 6 | // Copyright © 2019 Jianqiu Xiao. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class UserHeaderView: UITableViewHeaderFooterView { 12 | 13 | private var avatarView: UIImageView! 14 | private var createdAtLabel: UILabel! 15 | private var idLabel: UILabel! 16 | private var nameLabel: UILabel! 17 | public var user: User? { didSet { didSetUser() } } 18 | 19 | override init(reuseIdentifier: String?) { 20 | super.init(reuseIdentifier: reuseIdentifier) 21 | 22 | let stackView = UIStackView() 23 | stackView.alignment = .center 24 | stackView.axis = .vertical 25 | stackView.spacing = 12 26 | contentView.addSubview(stackView) 27 | stackView.snp.makeConstraints { make in 28 | make.leadingMargin.trailingMargin.equalToSuperview() 29 | make.top.equalToSuperview().offset(20) 30 | make.bottom.equalToSuperview().offset(-20).priority(999) 31 | } 32 | 33 | avatarView = UIImageView() 34 | avatarView.backgroundColor = .secondarySystemGroupedBackground 35 | avatarView.clipsToBounds = true 36 | avatarView.layer.cornerRadius = 44 37 | avatarView.snp.makeConstraints { $0.size.equalTo(88) } 38 | stackView.addArrangedSubview(avatarView) 39 | 40 | nameLabel = UILabel() 41 | nameLabel.font = .preferredFont(forTextStyle: .title1) 42 | stackView.addArrangedSubview(nameLabel) 43 | 44 | idLabel = UILabel() 45 | idLabel.font = .preferredFont(forTextStyle: .body) 46 | stackView.addArrangedSubview(idLabel) 47 | 48 | createdAtLabel = UILabel() 49 | createdAtLabel.font = .preferredFont(forTextStyle: .body) 50 | createdAtLabel.textColor = .secondaryLabel 51 | stackView.addArrangedSubview(createdAtLabel) 52 | } 53 | 54 | @available(*, unavailable) 55 | required init?(coder aDecoder: NSCoder) { 56 | fatalError("init(coder:) has not been implemented") 57 | } 58 | 59 | private func didSetUser() { 60 | avatarView.setImage(withURL: user?.avatar) 61 | createdAtLabel.text = user?.createdAt != nil ? "加入于\(user?.createdAt?.toRelative() ?? "")" : nil 62 | idLabel.text = user?.id != nil ? "第 \(user?.id ?? 0) 号会员" : nil 63 | nameLabel.text = user?.name 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /V2EX/Classes/Views/SignIn/SignInCaptchaCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SignInCaptchaCell.swift 3 | // V2EX 4 | // 5 | // Created by Jianqiu Xiao on 4/17/19. 6 | // Copyright © 2019 Jianqiu Xiao. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class SignInCaptchaCell: UITableViewCell { 12 | 13 | public var captchaField: UITextField! 14 | public var captchaImageView: UIImageView! 15 | 16 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 17 | super.init(style: style, reuseIdentifier: reuseIdentifier) 18 | 19 | selectionStyle = .none 20 | 21 | textLabel?.text = " " 22 | 23 | let stackView = UIStackView() 24 | stackView.spacing = 15 25 | contentView.addSubview(stackView) 26 | stackView.snp.makeConstraints { $0.edges.equalTo(textLabel ?? .init()) } 27 | 28 | captchaField = UITextField() 29 | captchaField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged) 30 | captchaField.autocapitalizationType = .none 31 | captchaField.autocorrectionType = .no 32 | captchaField.clearButtonMode = .whileEditing 33 | captchaField.delegate = self 34 | captchaField.font = .preferredFont(forTextStyle: .body) 35 | captchaField.placeholder = "验证码" 36 | captchaField.returnKeyType = .join 37 | stackView.addArrangedSubview(captchaField) 38 | 39 | captchaImageView = UIImageView() 40 | captchaImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(fetchData))) 41 | captchaImageView.isUserInteractionEnabled = true 42 | captchaImageView.snp.makeConstraints { $0.width.equalTo(captchaImageView.snp.height).multipliedBy(176 / 44.0) } 43 | stackView.addArrangedSubview(captchaImageView) 44 | } 45 | 46 | @available(*, unavailable) 47 | required init?(coder aDecoder: NSCoder) { 48 | fatalError("init(coder:) has not been implemented") 49 | } 50 | 51 | @objc 52 | private func textFieldDidChange(_ textField: UITextField) { 53 | (viewController as? SignInController)?.session?.captcha = textField.text 54 | } 55 | 56 | @objc 57 | private func fetchData() { 58 | (viewController as? SignInController)?.fetchData() 59 | } 60 | } 61 | 62 | extension SignInCaptchaCell: UITextFieldDelegate { 63 | 64 | func textFieldShouldReturn(_ textField: UITextField) -> Bool { 65 | (viewController as? SignInController)?.signIn() 66 | return false 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /V2EX/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "20x20", 5 | "idiom" : "iphone", 6 | "filename" : "Icon-Small-20@2x.png", 7 | "scale" : "2x" 8 | }, 9 | { 10 | "size" : "20x20", 11 | "idiom" : "iphone", 12 | "filename" : "Icon-Small-20@3x.png", 13 | "scale" : "3x" 14 | }, 15 | { 16 | "size" : "29x29", 17 | "idiom" : "iphone", 18 | "filename" : "Icon-Small@2x.png", 19 | "scale" : "2x" 20 | }, 21 | { 22 | "size" : "29x29", 23 | "idiom" : "iphone", 24 | "filename" : "Icon-Small@3x.png", 25 | "scale" : "3x" 26 | }, 27 | { 28 | "size" : "40x40", 29 | "idiom" : "iphone", 30 | "filename" : "Icon-Small-40@2x.png", 31 | "scale" : "2x" 32 | }, 33 | { 34 | "size" : "40x40", 35 | "idiom" : "iphone", 36 | "filename" : "Icon-60@2x.png", 37 | "scale" : "3x" 38 | }, 39 | { 40 | "size" : "60x60", 41 | "idiom" : "iphone", 42 | "filename" : "Icon-60@2x.png", 43 | "scale" : "2x" 44 | }, 45 | { 46 | "size" : "60x60", 47 | "idiom" : "iphone", 48 | "filename" : "Icon-60@3x.png", 49 | "scale" : "3x" 50 | }, 51 | { 52 | "size" : "20x20", 53 | "idiom" : "ipad", 54 | "filename" : "Icon-Small-20.png", 55 | "scale" : "1x" 56 | }, 57 | { 58 | "size" : "20x20", 59 | "idiom" : "ipad", 60 | "filename" : "Icon-Small-20@2x.png", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "size" : "29x29", 65 | "idiom" : "ipad", 66 | "filename" : "Icon-Small.png", 67 | "scale" : "1x" 68 | }, 69 | { 70 | "size" : "29x29", 71 | "idiom" : "ipad", 72 | "filename" : "Icon-Small@2x.png", 73 | "scale" : "2x" 74 | }, 75 | { 76 | "size" : "40x40", 77 | "idiom" : "ipad", 78 | "filename" : "Icon-Small-20@2x.png", 79 | "scale" : "1x" 80 | }, 81 | { 82 | "size" : "40x40", 83 | "idiom" : "ipad", 84 | "filename" : "Icon-Small-40@2x.png", 85 | "scale" : "2x" 86 | }, 87 | { 88 | "size" : "76x76", 89 | "idiom" : "ipad", 90 | "filename" : "Icon-76.png", 91 | "scale" : "1x" 92 | }, 93 | { 94 | "size" : "76x76", 95 | "idiom" : "ipad", 96 | "filename" : "Icon-76@2x.png", 97 | "scale" : "2x" 98 | }, 99 | { 100 | "size" : "83.5x83.5", 101 | "idiom" : "ipad", 102 | "filename" : "Icon-83.5@2x.png", 103 | "scale" : "2x" 104 | }, 105 | { 106 | "size" : "1024x1024", 107 | "idiom" : "ios-marketing", 108 | "filename" : "Icon-1024.png", 109 | "scale" : "1x" 110 | } 111 | ], 112 | "info" : { 113 | "version" : 1, 114 | "author" : "xcode" 115 | } 116 | } -------------------------------------------------------------------------------- /V2EX.xcodeproj/xcshareddata/xcschemes/V2EX.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /V2EX/Classes/Controllers/ViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // V2EX 4 | // 5 | // Created by Jianqiu Xiao on 4/8/19. 6 | // Copyright © 2019 Jianqiu Xiao. All rights reserved. 7 | // 8 | 9 | import JGProgressHUD 10 | 11 | class ViewController: UIViewController { 12 | 13 | private var progressHUD: JGProgressHUD? 14 | 15 | init() { 16 | super.init(nibName: nil, bundle: nil) 17 | 18 | userActivity = NSUserActivity(activityType: NSUserActivityTypeBrowsingWeb) 19 | } 20 | 21 | @available(*, unavailable) 22 | required init?(coder aDecoder: NSCoder) { 23 | fatalError("init(coder:) has not been implemented") 24 | } 25 | 26 | override func viewWillAppear(_ animated: Bool) { 27 | super.viewWillAppear(animated) 28 | 29 | userActivity?.becomeCurrent() 30 | } 31 | 32 | override func viewDidDisappear(_ animated: Bool) { 33 | super.viewDidDisappear(animated) 34 | 35 | userActivity?.invalidate() 36 | } 37 | 38 | internal var baseURL: URL { 39 | return URL(string: "https://www.v2ex.com") ?? .init(fileURLWithPath: "") 40 | } 41 | 42 | @objc 43 | internal func dismiss(_ sender: Any? = nil) { 44 | view.endEditing(true) 45 | dismiss(animated: true) 46 | } 47 | 48 | internal func hideHUD() { 49 | progressHUD?.dismiss(animated: false) 50 | } 51 | 52 | internal func networkError() { 53 | let alertController = UIAlertController(title: "网络错误", message: nil, preferredStyle: .alert) 54 | alertController.addAction(UIAlertAction(title: "好", style: .default)) 55 | present(alertController, animated: true) 56 | } 57 | 58 | internal func showHUD() { 59 | var ancestor: UIViewController = self 60 | while let parent = ancestor.parent { ancestor = parent } 61 | let style: JGProgressHUDStyle = traitCollection.userInterfaceStyle != .dark ? .extraLight : .dark 62 | if progressHUD?.style != style { 63 | progressHUD = JGProgressHUD(style: style) 64 | } 65 | progressHUD?.show(in: ancestor.view, animated: false) 66 | } 67 | 68 | internal func signInRequired(_ sender: Any) { 69 | let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) 70 | alertController.addAction(UIAlertAction(title: "登录", style: .default) { _ in 71 | let tabBarController = self.tabBarController 72 | self.navigationController?.popToRootViewController(animated: false) 73 | tabBarController?.selectedIndex = 0 74 | let navigationController = tabBarController?.selectedViewController as? UINavigationController 75 | navigationController?.popToRootViewController(animated: false) 76 | (navigationController?.viewControllers.first as? TopicsController)?.showSignIn() 77 | }) 78 | alertController.addAction(UIAlertAction(title: "取消", style: .cancel)) 79 | alertController.popoverPresentationController?.barButtonItem = sender as? UIBarButtonItem 80 | alertController.popoverPresentationController?.sourceRect = (sender as? UIView)?.bounds ?? .zero 81 | alertController.popoverPresentationController?.sourceView = sender as? UIView 82 | present(alertController, animated: true) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /V2EX/Classes/Controllers/ComposeController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ComposeController.swift 3 | // V2EX 4 | // 5 | // Created by Jianqiu Xiao on 6/8/19. 6 | // Copyright © 2019 Jianqiu Xiao. All rights reserved. 7 | // 8 | 9 | import Alamofire 10 | 11 | class ComposeController: ViewController { 12 | 13 | public var reply: Reply? 14 | private var textView: UITextView! 15 | public var topic: Topic? 16 | 17 | override init() { 18 | super.init() 19 | 20 | navigationItem.leftBarButtonItem = UIBarButtonItem(title: "放弃", style: .plain, target: self, action: #selector(discard)) 21 | navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(done)) 22 | navigationItem.rightBarButtonItem?.isEnabled = false 23 | 24 | title = "回复" 25 | } 26 | 27 | override func loadView() { 28 | textView = UITextView() 29 | textView.alwaysBounceVertical = true 30 | textView.contentInset = UIEdgeInsets(top: 3, left: 15, bottom: 3, right: 15) 31 | textView.delegate = self 32 | textView.font = .preferredFont(forTextStyle: .body) 33 | textView.text = reply != nil ? "@\(reply?.user?.name ?? "") " : nil 34 | view = textView 35 | } 36 | 37 | override func viewWillAppear(_ animated: Bool) { 38 | super.viewWillAppear(animated) 39 | 40 | textView.becomeFirstResponder() 41 | } 42 | 43 | @objc 44 | private func discard() { 45 | view.endEditing(true) 46 | if textView.text == "" { return dismiss(animated: true) } 47 | let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) 48 | alertController.addAction(UIAlertAction(title: "放弃", style: .destructive) { _ in 49 | self.dismiss(animated: true) 50 | }) 51 | alertController.addAction(UIAlertAction(title: "继续", style: .cancel)) 52 | present(alertController, animated: true) 53 | } 54 | 55 | @objc 56 | private func done() { 57 | view.endEditing(true) 58 | showHUD() 59 | let url = baseURL 60 | .appendingPathComponent("t") 61 | .appendingPathComponent(String(topic?.id ?? 0)) 62 | AF.request( 63 | url, 64 | method: .post, 65 | parameters: [ 66 | "content": textView.text ?? "", 67 | "once": topic?.once ?? "", 68 | ], 69 | headers: [ 70 | "Referer": url.absoluteString, 71 | ] 72 | ) 73 | .responseString { response in 74 | if 200..<300 ~= response.response?.statusCode ?? 0 { 75 | let alertController = UIAlertController(title: "已回复", message: nil, preferredStyle: .alert) 76 | alertController.addAction(UIAlertAction(title: "好", style: .default) { _ in 77 | self.dismiss(animated: true) 78 | }) 79 | self.present(alertController, animated: true) 80 | } else { 81 | self.networkError() 82 | } 83 | self.hideHUD() 84 | } 85 | } 86 | } 87 | 88 | extension ComposeController: UITextViewDelegate { 89 | 90 | func textViewDidChange(_ textView: UITextView) { 91 | navigationItem.rightBarButtonItem?.isEnabled = textView.text != "" 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /V2EX/Classes/Controllers/WebViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WebViewController.swift 3 | // V2EX 4 | // 5 | // Created by Jianqiu Xiao on 4/11/19. 6 | // Copyright © 2019 Jianqiu Xiao. All rights reserved. 7 | // 8 | 9 | import JXWebViewController 10 | import TUSafariActivity 11 | import WebKit 12 | 13 | class WebViewController: JXWebViewController { 14 | 15 | private var activityIndicatorView: ActivityIndicatorView! 16 | private var isRefreshing = false { didSet { didSetRefreshing() } } 17 | private var networkErrorView: NetworkErrorView! 18 | public var url: URL? 19 | 20 | override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { 21 | super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) 22 | 23 | navigationItem.largeTitleDisplayMode = .never 24 | navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .action, target: self, action: #selector(action)) 25 | } 26 | 27 | override func loadView() { 28 | super.loadView() 29 | 30 | if title != nil { 31 | webViewKeyValueObservations[\WKWebView.title] = nil 32 | } 33 | 34 | activityIndicatorView = ActivityIndicatorView() 35 | view.addSubview(activityIndicatorView) 36 | 37 | networkErrorView = NetworkErrorView() 38 | networkErrorView.addGestureRecognizer(UITapGestureRecognizer(target: webView, action: #selector(webView.reload))) 39 | view.addSubview(networkErrorView) 40 | } 41 | 42 | override func viewWillAppear(_ animated: Bool) { 43 | super.viewWillAppear(animated) 44 | 45 | if webView.url == nil, let url = url { 46 | webView.load(URLRequest(url: url)) 47 | } 48 | } 49 | 50 | private func didSetRefreshing() { 51 | if isRefreshing { 52 | networkErrorView.isHidden = true 53 | activityIndicatorView.startAnimating() 54 | } else { 55 | activityIndicatorView.stopAnimating() 56 | } 57 | } 58 | 59 | @objc 60 | private func action(_ barButtonItem: UIBarButtonItem) { 61 | guard let url = webView.url else { return } 62 | let activityViewController = UIActivityViewController(activityItems: [url], applicationActivities: [TUSafariActivity()]) 63 | activityViewController.popoverPresentationController?.barButtonItem = barButtonItem 64 | present(activityViewController, animated: true) 65 | } 66 | } 67 | 68 | extension WebViewController { 69 | 70 | func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { 71 | isRefreshing = true 72 | } 73 | 74 | override func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { 75 | super.webView(webView, didFailProvisionalNavigation: navigation, withError: error) 76 | 77 | isRefreshing = false 78 | networkErrorView.isHidden = false 79 | } 80 | 81 | override func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { 82 | super.webView(webView, didFail: navigation, withError: error) 83 | 84 | isRefreshing = false 85 | networkErrorView.isHidden = false 86 | } 87 | 88 | override func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { 89 | super.webView(webView, didFinish: navigation) 90 | 91 | isRefreshing = false 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /V2EX/Classes/Controllers/MoreController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MoreController.swift 3 | // V2EX 4 | // 5 | // Created by Jianqiu Xiao on 6/17/19. 6 | // Copyright © 2019 Jianqiu Xiao. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class MoreController: ViewController { 12 | 13 | private var tableView: UITableView! 14 | 15 | override init() { 16 | super.init() 17 | 18 | title = "更多" 19 | } 20 | 21 | override func loadView() { 22 | tableView = UITableView(frame: .zero, style: .grouped) 23 | tableView.cellLayoutMarginsFollowReadableWidth = true 24 | tableView.dataSource = self 25 | tableView.delegate = self 26 | view = tableView 27 | } 28 | 29 | override func viewWillAppear(_ animated: Bool) { 30 | super.viewWillAppear(animated) 31 | 32 | tableView.indexPathsForSelectedRows?.forEach { tableView.deselectRow(at: $0, animated: animated) } 33 | } 34 | } 35 | 36 | extension MoreController: UITableViewDataSource { 37 | 38 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 39 | return 4 40 | } 41 | 42 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 43 | switch indexPath.row { 44 | case 0: 45 | let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) 46 | cell.accessoryType = .disclosureIndicator 47 | cell.detailTextLabel?.text = Bundle.main.infoDictionary?[kCFBundleNameKey as String] as? String 48 | cell.textLabel?.text = "关于" 49 | return cell 50 | 51 | case 1: 52 | let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) 53 | cell.accessoryType = .disclosureIndicator 54 | cell.detailTextLabel?.text = "GitHub" 55 | cell.textLabel?.text = "反馈" 56 | return cell 57 | 58 | case 2: 59 | let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) 60 | cell.accessoryType = .disclosureIndicator 61 | let diskUsage = URLCache.shared.currentDiskUsage / 1_024 / 1_024 62 | cell.detailTextLabel?.text = diskUsage > 0 ? "\(diskUsage) MB" : "0" 63 | cell.textLabel?.text = "缓存" 64 | return cell 65 | 66 | case 3: 67 | let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) 68 | let shortVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" 69 | let version = Bundle.main.infoDictionary?[kCFBundleVersionKey as String] as? String ?? "" 70 | cell.detailTextLabel?.text = "\(shortVersion) (\(version))" 71 | cell.selectionStyle = .none 72 | cell.textLabel?.text = "版本" 73 | return cell 74 | 75 | default: 76 | return .init() 77 | } 78 | } 79 | } 80 | 81 | extension MoreController: UITableViewDelegate { 82 | 83 | func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 84 | switch indexPath.row { 85 | case 0: 86 | let webViewController = WebViewController() 87 | webViewController.title = "关于" 88 | webViewController.url = baseURL.appendingPathComponent("about") 89 | navigationController?.pushViewController(webViewController, animated: true) 90 | 91 | case 1: 92 | let webViewController = WebViewController() 93 | webViewController.title = "反馈" 94 | webViewController.url = URL(string: "https://github.com/swordray/v2ex-ios/issues") 95 | navigationController?.pushViewController(webViewController, animated: true) 96 | 97 | case 2: 98 | URLCache.shared.removeAllCachedResponses() 99 | tableView.reloadRows(at: [indexPath], with: .automatic) 100 | 101 | default: 102 | break 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /V2EX/zh-Hans.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /V2EX/Classes/Controllers/NodesController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NodesController.swift 3 | // V2EX 4 | // 5 | // Created by Jianqiu Xiao on 4/19/19. 6 | // Copyright © 2019 Jianqiu Xiao. All rights reserved. 7 | // 8 | 9 | import Alamofire 10 | import Kanna 11 | 12 | class NodesController: ViewController { 13 | 14 | private var activityIndicatorView: ActivityIndicatorView! 15 | private var isRefreshing = false { didSet { didSetRefreshing() } } 16 | private var networkErrorView: NetworkErrorView! 17 | private var sections: [Section] = [] 18 | private var tableView: UITableView! 19 | 20 | override init() { 21 | super.init() 22 | 23 | title = "节点" 24 | } 25 | 26 | override func loadView() { 27 | tableView = UITableView() 28 | tableView.cellLayoutMarginsFollowReadableWidth = true 29 | tableView.dataSource = self 30 | tableView.delegate = self 31 | tableView.refreshControl = UIRefreshControl() 32 | tableView.refreshControl?.addTarget(self, action: #selector(fetchData), for: .valueChanged) 33 | tableView.register(UITableViewCell.self, forCellReuseIdentifier: UITableViewCell.description()) 34 | tableView.tableFooterView = UIView() 35 | view = tableView 36 | 37 | activityIndicatorView = ActivityIndicatorView() 38 | view.addSubview(activityIndicatorView) 39 | 40 | networkErrorView = NetworkErrorView() 41 | networkErrorView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(fetchData))) 42 | view.addSubview(networkErrorView) 43 | } 44 | 45 | override func viewWillAppear(_ animated: Bool) { 46 | super.viewWillAppear(animated) 47 | 48 | tableView.indexPathsForSelectedRows?.forEach { tableView.deselectRow(at: $0, animated: animated) } 49 | 50 | if sections.count == 0 || !networkErrorView.isHidden { fetchData() } 51 | } 52 | 53 | @objc 54 | private func fetchData() { 55 | if isRefreshing { return } 56 | isRefreshing = true 57 | AF.request( 58 | baseURL 59 | ) 60 | .responseString { response in 61 | if 200..<300 ~= response.response?.statusCode ?? 0 { 62 | let doc = try? HTML(html: response.value ?? "", encoding: .utf8) 63 | let json = doc?.css("#Main .box:nth-child(4) .cell:not(:first-child)").map { 64 | [ 65 | "name": $0.at_css(".fade")?.text, 66 | "nodes": $0.css("a").map { 67 | [ 68 | "name": $0.text, 69 | "code": "[^/]+$".r?.findFirst(in: $0["href"] ?? "")?.matched, 70 | ] 71 | }, 72 | ] as [String: Any?] 73 | } 74 | self.sections = (try? [Section](json: json ?? [])) ?? [] 75 | self.tableView.reloadData() 76 | } else { 77 | self.networkErrorView.isHidden = false 78 | } 79 | self.isRefreshing = false 80 | } 81 | } 82 | 83 | private func didSetRefreshing() { 84 | if isRefreshing { 85 | networkErrorView.isHidden = true 86 | if tableView.refreshControl?.isRefreshing ?? false { return } 87 | activityIndicatorView.startAnimating() 88 | } else { 89 | tableView.refreshControl?.endRefreshing() 90 | activityIndicatorView.stopAnimating() 91 | } 92 | } 93 | } 94 | 95 | extension NodesController: UITableViewDataSource { 96 | 97 | func numberOfSections(in tableView: UITableView) -> Int { 98 | return sections.count 99 | } 100 | 101 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 102 | return sections[section].nodes.count 103 | } 104 | 105 | func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { 106 | return sections[section].name 107 | } 108 | 109 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 110 | let cell = tableView.dequeueReusableCell(withIdentifier: UITableViewCell.description(), for: indexPath) 111 | cell.accessoryType = .disclosureIndicator 112 | cell.textLabel?.text = sections[indexPath.section].nodes[indexPath.row].name 113 | return cell 114 | } 115 | } 116 | 117 | extension NodesController: UITableViewDelegate { 118 | 119 | func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 120 | let topicsController = TopicsController() 121 | topicsController.node = sections[indexPath.section].nodes[indexPath.row] 122 | navigationController?.pushViewController(topicsController, animated: true) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /V2EX/Classes/Views/Topics/TopicsCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cell.swift 3 | // V2EX 4 | // 5 | // Created by Jianqiu Xiao on 4/8/19. 6 | // Copyright © 2019 Jianqiu Xiao. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class TopicsCell: UITableViewCell { 12 | 13 | private var nameLabel: UILabel! 14 | private var nodeButton: UIButton! 15 | private var repliedAtLabel: UILabel! 16 | private var repliesCountLabel: UILabel! 17 | public var tableViewStyle: UITableView.Style? 18 | public var topic: Topic? { didSet { didSetTopic() } } 19 | private var userAvatarView: UIImageView! 20 | private var userNameLabel: UILabel! 21 | 22 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 23 | super.init(style: style, reuseIdentifier: reuseIdentifier) 24 | 25 | accessoryType = .disclosureIndicator 26 | 27 | let stackView = UIStackView() 28 | stackView.alignment = .center 29 | stackView.spacing = 8 30 | contentView.addSubview(stackView) 31 | stackView.snp.makeConstraints { $0.margins.equalToSuperview().priority(999) } 32 | 33 | userAvatarView = UIImageView() 34 | userAvatarView.backgroundColor = .secondarySystemBackground 35 | userAvatarView.clipsToBounds = true 36 | userAvatarView.layer.cornerRadius = 22 37 | userAvatarView.snp.makeConstraints { $0.size.equalTo(44) } 38 | stackView.addArrangedSubview(userAvatarView) 39 | stackView.setCustomSpacing(15, after: userAvatarView) 40 | 41 | let contentStackView = UIStackView() 42 | contentStackView.axis = .vertical 43 | contentStackView.spacing = 8 44 | contentStackView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) 45 | contentStackView.setContentHuggingPriority(.defaultLow, for: .horizontal) 46 | stackView.addArrangedSubview(contentStackView) 47 | 48 | nameLabel = UILabel() 49 | nameLabel.font = .preferredFont(forTextStyle: .body) 50 | nameLabel.numberOfLines = 3 51 | contentStackView.addArrangedSubview(nameLabel) 52 | 53 | let detailStackView = UIStackView() 54 | detailStackView.spacing = 8 55 | contentStackView.addArrangedSubview(detailStackView) 56 | 57 | nodeButton = UIButton() 58 | nodeButton.backgroundColor = .quaternarySystemFill 59 | nodeButton.clipsToBounds = true 60 | nodeButton.contentEdgeInsets = UIEdgeInsets(top: 3, left: 3, bottom: 3, right: 3) 61 | nodeButton.isUserInteractionEnabled = false 62 | nodeButton.layer.cornerRadius = 3 63 | nodeButton.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) 64 | nodeButton.setContentHuggingPriority(.defaultHigh, for: .horizontal) 65 | nodeButton.setTitleColor(.secondaryLabel, for: .normal) 66 | nodeButton.titleLabel?.font = .preferredFont(forTextStyle: .subheadline) 67 | detailStackView.addArrangedSubview(nodeButton) 68 | 69 | userNameLabel = UILabel() 70 | userNameLabel.font = .preferredFont(forTextStyle: .subheadline) 71 | userNameLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) 72 | userNameLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) 73 | userNameLabel.textColor = .secondaryLabel 74 | detailStackView.addArrangedSubview(userNameLabel) 75 | 76 | repliedAtLabel = UILabel() 77 | repliedAtLabel.font = .preferredFont(forTextStyle: .subheadline) 78 | repliedAtLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) 79 | repliedAtLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) 80 | repliedAtLabel.textColor = .tertiaryLabel 81 | detailStackView.addArrangedSubview(repliedAtLabel) 82 | 83 | repliesCountLabel = UILabel() 84 | repliesCountLabel.font = .preferredFont(forTextStyle: .body) 85 | repliesCountLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) 86 | repliesCountLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) 87 | repliesCountLabel.textColor = .secondaryLabel 88 | stackView.addArrangedSubview(repliesCountLabel) 89 | } 90 | 91 | @available(*, unavailable) 92 | required init?(coder aDecoder: NSCoder) { 93 | fatalError("init(coder:) has not been implemented") 94 | } 95 | 96 | private func didSetTopic() { 97 | backgroundColor = topic?.isSticky ?? false ? .secondarySystemBackground : tableViewStyle == .plain ? .systemBackground : .secondarySystemGroupedBackground 98 | nameLabel.text = topic?.name 99 | nodeButton.isHidden = topic?.node?.name == nil 100 | nodeButton.setTitle(topic?.node?.name, for: .normal) 101 | repliedAtLabel.text = topic?.repliedAt 102 | repliesCountLabel.text = String(topic?.repliesCount ?? 0) 103 | userAvatarView.isHidden = topic?.user == nil 104 | userAvatarView.setImage(withURL: topic?.user?.avatar) 105 | userNameLabel.isHidden = topic?.user == nil 106 | userNameLabel.text = topic?.user?.name 107 | 108 | if topic?.user != nil { 109 | let size = CGSize(width: 44, height: 1) 110 | UIGraphicsBeginImageContextWithOptions(size, false, UIScreen.main.scale) 111 | let image = UIGraphicsGetImageFromCurrentImageContext() 112 | UIGraphicsEndImageContext() 113 | imageView?.image = image 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /V2EX/Classes/Views/Topic/TopicNameCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NameCell.swift 3 | // V2EX 4 | // 5 | // Created by Jianqiu Xiao on 4/9/19. 6 | // Copyright © 2019 Jianqiu Xiao. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class TopicNameCell: UITableViewCell { 12 | 13 | private var createdAtLabel: UILabel! 14 | private var nameLabel: UILabel! 15 | private var nodeButton: UIButton! 16 | public var topic: Topic? { didSet { didSetTopic() } } 17 | private var userAvatarView: UIImageView! 18 | private var userNameButton: UIButton! 19 | 20 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 21 | super.init(style: style, reuseIdentifier: reuseIdentifier) 22 | 23 | selectionStyle = .none 24 | 25 | let stackView = UIStackView() 26 | stackView.axis = .vertical 27 | stackView.spacing = 8 28 | contentView.addSubview(stackView) 29 | stackView.snp.makeConstraints { $0.margins.equalToSuperview().priority(999) } 30 | 31 | nameLabel = UILabel() 32 | nameLabel.font = .preferredFont(forTextStyle: .title2) 33 | nameLabel.numberOfLines = 0 34 | stackView.addArrangedSubview(nameLabel) 35 | 36 | let detailStackView = UIStackView() 37 | detailStackView.alignment = .center 38 | detailStackView.spacing = 8 39 | stackView.addArrangedSubview(detailStackView) 40 | 41 | userAvatarView = UIImageView() 42 | userAvatarView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showUser))) 43 | userAvatarView.backgroundColor = .secondarySystemBackground 44 | userAvatarView.clipsToBounds = true 45 | userAvatarView.isUserInteractionEnabled = true 46 | userAvatarView.layer.cornerRadius = 22 47 | userAvatarView.snp.makeConstraints { $0.size.equalTo(44) } 48 | detailStackView.addArrangedSubview(userAvatarView) 49 | detailStackView.setCustomSpacing(15, after: userAvatarView) 50 | 51 | userNameButton = UIButton() 52 | userNameButton.addTarget(self, action: #selector(showUser), for: .touchUpInside) 53 | userNameButton.contentEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: .leastNormalMagnitude) 54 | userNameButton.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) 55 | userNameButton.setContentHuggingPriority(.defaultHigh, for: .horizontal) 56 | userNameButton.setTitleColor(tintColor, for: .normal) 57 | userNameButton.setTitleColor(tintColor.withAlphaComponent(0.2), for: .highlighted) 58 | userNameButton.titleLabel?.font = .preferredFont(forTextStyle: .body) 59 | detailStackView.addArrangedSubview(userNameButton) 60 | 61 | let flexibleSpaceView = UIView() 62 | detailStackView.addArrangedSubview(flexibleSpaceView) 63 | detailStackView.setCustomSpacing(0, after: flexibleSpaceView) 64 | 65 | nodeButton = UIButton() 66 | nodeButton.addTarget(self, action: #selector(showNode), for: .touchUpInside) 67 | nodeButton.backgroundColor = .quaternarySystemFill 68 | nodeButton.clipsToBounds = true 69 | nodeButton.contentEdgeInsets = UIEdgeInsets(top: 3, left: 3, bottom: 3, right: 3) 70 | nodeButton.layer.cornerRadius = 3 71 | nodeButton.titleLabel?.font = .preferredFont(forTextStyle: .body) 72 | nodeButton.setTitleColor(tintColor, for: .normal) 73 | nodeButton.setTitleColor(tintColor.withAlphaComponent(0.2), for: .highlighted) 74 | detailStackView.addArrangedSubview(nodeButton) 75 | 76 | createdAtLabel = UILabel() 77 | createdAtLabel.font = .preferredFont(forTextStyle: .subheadline) 78 | createdAtLabel.numberOfLines = 0 79 | createdAtLabel.textColor = .secondaryLabel 80 | stackView.addArrangedSubview(createdAtLabel) 81 | } 82 | 83 | @available(*, unavailable) 84 | required init?(coder aDecoder: NSCoder) { 85 | fatalError("init(coder:) has not been implemented") 86 | } 87 | 88 | override func tintColorDidChange() { 89 | super.tintColorDidChange() 90 | 91 | userNameButton.setTitleColor(tintColor, for: .normal) 92 | nodeButton.setTitleColor(tintColor, for: .normal) 93 | } 94 | 95 | private func didSetTopic() { 96 | createdAtLabel.text = [ 97 | topic?.createdAt, 98 | topic?.clicksCount != nil ? "\(topic?.clicksCount ?? 0) 次点击" : nil, 99 | topic?.favoritesCount != nil ? "\(topic?.favoritesCount ?? 0) 人收藏" : nil, 100 | ].compactMap { $0 }.joined(separator: " · ") + " " 101 | nameLabel.text = topic?.name ?? " " 102 | nodeButton.isHidden = topic?.node?.name == nil 103 | nodeButton.setTitle(topic?.node?.name, for: .normal) 104 | userAvatarView.setImage(withURL: topic?.user?.avatar) 105 | userNameButton.setTitle(topic?.user?.name, for: .normal) 106 | } 107 | 108 | @objc 109 | private func showUser() { 110 | guard let user = topic?.user else { return } 111 | let userController = UserController() 112 | userController.user = user 113 | viewController?.navigationController?.pushViewController(userController, animated: true) 114 | } 115 | 116 | @objc 117 | private func showNode() { 118 | guard let node = topic?.node else { return } 119 | let topicsController = TopicsController() 120 | topicsController.node = node 121 | viewController?.navigationController?.pushViewController(topicsController, animated: true) 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - Alamofire (5.0.0-rc.2) 3 | - AlamofireImage (4.0.0-beta.5): 4 | - Alamofire (~> 5.0.0-rc.2) 5 | - CrossroadRegex (1.1.0) 6 | - Firebase/Core (6.9.0): 7 | - Firebase/CoreOnly 8 | - FirebaseAnalytics (= 6.1.2) 9 | - Firebase/CoreOnly (6.9.0): 10 | - FirebaseCore (= 6.3.0) 11 | - FirebaseAnalytics (6.1.2): 12 | - FirebaseCore (~> 6.3) 13 | - FirebaseInstanceID (~> 4.2) 14 | - GoogleAppMeasurement (= 6.1.2) 15 | - GoogleUtilities/AppDelegateSwizzler (~> 6.0) 16 | - GoogleUtilities/MethodSwizzler (~> 6.0) 17 | - GoogleUtilities/Network (~> 6.0) 18 | - "GoogleUtilities/NSData+zlib (~> 6.0)" 19 | - nanopb (~> 0.3) 20 | - FirebaseCore (6.3.0): 21 | - FirebaseCoreDiagnostics (~> 1.0) 22 | - FirebaseCoreDiagnosticsInterop (~> 1.0) 23 | - GoogleUtilities/Environment (~> 6.2) 24 | - GoogleUtilities/Logger (~> 6.2) 25 | - FirebaseCoreDiagnostics (1.1.0): 26 | - FirebaseCoreDiagnosticsInterop (~> 1.0) 27 | - GoogleDataTransportCCTSupport (~> 1.0) 28 | - GoogleUtilities/Environment (~> 6.2) 29 | - GoogleUtilities/Logger (~> 6.2) 30 | - FirebaseCoreDiagnosticsInterop (1.0.0) 31 | - FirebaseInstanceID (4.2.5): 32 | - FirebaseCore (~> 6.0) 33 | - GoogleUtilities/Environment (~> 6.0) 34 | - GoogleUtilities/UserDefaults (~> 6.0) 35 | - GoogleAppMeasurement (6.1.2): 36 | - GoogleUtilities/AppDelegateSwizzler (~> 6.0) 37 | - GoogleUtilities/MethodSwizzler (~> 6.0) 38 | - GoogleUtilities/Network (~> 6.0) 39 | - "GoogleUtilities/NSData+zlib (~> 6.0)" 40 | - nanopb (~> 0.3) 41 | - GoogleDataTransport (2.0.0) 42 | - GoogleDataTransportCCTSupport (1.1.0): 43 | - GoogleDataTransport (~> 2.0) 44 | - nanopb 45 | - GoogleUtilities/AppDelegateSwizzler (6.3.1): 46 | - GoogleUtilities/Environment 47 | - GoogleUtilities/Logger 48 | - GoogleUtilities/Network 49 | - GoogleUtilities/Environment (6.3.1) 50 | - GoogleUtilities/Logger (6.3.1): 51 | - GoogleUtilities/Environment 52 | - GoogleUtilities/MethodSwizzler (6.3.1): 53 | - GoogleUtilities/Logger 54 | - GoogleUtilities/Network (6.3.1): 55 | - GoogleUtilities/Logger 56 | - "GoogleUtilities/NSData+zlib" 57 | - GoogleUtilities/Reachability 58 | - "GoogleUtilities/NSData+zlib (6.3.1)" 59 | - GoogleUtilities/Reachability (6.3.1): 60 | - GoogleUtilities/Logger 61 | - GoogleUtilities/UserDefaults (6.3.1): 62 | - GoogleUtilities/Logger 63 | - JGProgressHUD (2.0.4) 64 | - JXWebViewController (1.2.1) 65 | - Kanna (5.0.0) 66 | - KeychainAccess (3.2.0) 67 | - Ladybug (2.0.0) 68 | - nanopb (0.3.901): 69 | - nanopb/decode (= 0.3.901) 70 | - nanopb/encode (= 0.3.901) 71 | - nanopb/decode (0.3.901) 72 | - nanopb/encode (0.3.901) 73 | - SnapKit (5.0.1) 74 | - SwiftDate (6.1.0) 75 | - SwiftLint (0.35.0) 76 | - TUSafariActivity (1.0.4) 77 | - UIButtonSetBackgroundColorForState (0.1.0) 78 | 79 | DEPENDENCIES: 80 | - Alamofire (~> 5.0.0-rc.2) 81 | - AlamofireImage (~> 4.0.0-beta.5) 82 | - CrossroadRegex 83 | - Firebase/Core 84 | - JGProgressHUD 85 | - JXWebViewController 86 | - Kanna 87 | - KeychainAccess 88 | - Ladybug 89 | - SnapKit 90 | - SwiftDate 91 | - SwiftLint 92 | - TUSafariActivity 93 | - UIButtonSetBackgroundColorForState 94 | 95 | SPEC REPOS: 96 | trunk: 97 | - Alamofire 98 | - AlamofireImage 99 | - CrossroadRegex 100 | - Firebase 101 | - FirebaseAnalytics 102 | - FirebaseCore 103 | - FirebaseCoreDiagnostics 104 | - FirebaseCoreDiagnosticsInterop 105 | - FirebaseInstanceID 106 | - GoogleAppMeasurement 107 | - GoogleDataTransport 108 | - GoogleDataTransportCCTSupport 109 | - GoogleUtilities 110 | - JGProgressHUD 111 | - JXWebViewController 112 | - Kanna 113 | - KeychainAccess 114 | - Ladybug 115 | - nanopb 116 | - SnapKit 117 | - SwiftDate 118 | - SwiftLint 119 | - TUSafariActivity 120 | - UIButtonSetBackgroundColorForState 121 | 122 | SPEC CHECKSUMS: 123 | Alamofire: f9450d3c7f6bea2ad62e7a541c3e9b186c7991d6 124 | AlamofireImage: f6dfe201f6d59cc6a045b5cb228b58962b8ef31d 125 | CrossroadRegex: 5eaadb3f7b4014728f1cbd1014756b126c1dbd9a 126 | Firebase: 2d750c54cda57d5a6ae31212cfe5cc813c6be7e4 127 | FirebaseAnalytics: 5d9ccbf46ed25d3ec9304d263f85bddf1e93e2d2 128 | FirebaseCore: 8b2765c445d40db7137989b7146a3aa3f91b5529 129 | FirebaseCoreDiagnostics: be4f7a09d02ab305f18de59a470412caddb64c2a 130 | FirebaseCoreDiagnosticsInterop: 6829da2b8d1fc795ff1bd99df751d3788035d2cb 131 | FirebaseInstanceID: 550df9be1f99f751d8fcde3ac342a1e21a0e6c42 132 | GoogleAppMeasurement: 0ae90be1cc4dad40f4a27fc767ef59fa032ec87b 133 | GoogleDataTransport: c8617c00e4f3eb9418e42ac0e8ac5241a9d555dd 134 | GoogleDataTransportCCTSupport: 9f352523e8785a71f6754f51eeff09f49ec19268 135 | GoogleUtilities: f895fde57977df4e0233edda0dbeac490e3703b6 136 | JGProgressHUD: 62658b14e72cccf179efc7a13bcb54d30b92fc22 137 | JXWebViewController: 939b9b9b104e53810e73dc51535294ded7c8abf0 138 | Kanna: a32875df62975f8f4b871e933b4ea1a5c3b8be0f 139 | KeychainAccess: 3b1bf8a77eb4c6ea1ce9404c292e48f948954c6b 140 | Ladybug: f445480856a4971ef401abcb14ff8ea2d1178785 141 | nanopb: 2901f78ea1b7b4015c860c2fdd1ea2fee1a18d48 142 | SnapKit: 97b92857e3df3a0c71833cce143274bf6ef8e5eb 143 | SwiftDate: fa2bb3962056bb44047b4b85a30044e5eae30b03 144 | SwiftLint: 5553187048b900c91aa03552807681bb6b027846 145 | TUSafariActivity: afc55a00965377939107ce4fdc7f951f62454546 146 | UIButtonSetBackgroundColorForState: fcc963a30f972e15434bacebefe042033940a438 147 | 148 | PODFILE CHECKSUM: fa691ca4f17477d264259c79840ed1e6e7068b9a 149 | 150 | COCOAPODS: 1.10.0.rc.1 151 | -------------------------------------------------------------------------------- /V2EX/Classes/Views/Topic/TopicReplyCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TopicReplyCell.swift 3 | // V2EX 4 | // 5 | // Created by Jianqiu Xiao on 4/10/19. 6 | // Copyright © 2019 Jianqiu Xiao. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class TopicReplyCell: TopicWebCell { 12 | 13 | private var createdAtLabel: UILabel! 14 | private var indexButton: UIButton! 15 | public var reply: Reply? { didSet { didSetReply() } } 16 | private var userAvatarView: UIImageView! 17 | private var userNameButton: UIButton! 18 | 19 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 20 | super.init(style: style, reuseIdentifier: reuseIdentifier) 21 | 22 | let size = CGSize(width: 44, height: 1) 23 | UIGraphicsBeginImageContextWithOptions(size, false, UIScreen.main.scale) 24 | let image = UIGraphicsGetImageFromCurrentImageContext() 25 | UIGraphicsEndImageContext() 26 | imageView?.image = image 27 | 28 | userAvatarView = UIImageView() 29 | userAvatarView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showUser))) 30 | userAvatarView.backgroundColor = .secondarySystemBackground 31 | userAvatarView.clipsToBounds = true 32 | userAvatarView.isUserInteractionEnabled = true 33 | userAvatarView.layer.cornerRadius = 22 34 | contentView.addSubview(userAvatarView) 35 | userAvatarView.snp.makeConstraints { make in 36 | make.leading.top.equalTo(contentView.layoutMarginsGuide) 37 | make.bottom.lessThanOrEqualTo(contentView.layoutMarginsGuide).priority(999) 38 | make.size.equalTo(44) 39 | } 40 | 41 | let stackView = UIStackView() 42 | stackView.axis = .vertical 43 | stackView.spacing = 8 44 | contentView.addSubview(stackView) 45 | stackView.snp.makeConstraints { make in 46 | make.trailing.top.equalTo(contentView.layoutMarginsGuide) 47 | make.bottom.lessThanOrEqualTo(contentView.layoutMarginsGuide) 48 | make.leading.equalTo(userAvatarView.snp.trailing).offset(15) 49 | } 50 | 51 | let detailStackView = UIStackView() 52 | detailStackView.spacing = 8 53 | stackView.addArrangedSubview(detailStackView) 54 | 55 | userNameButton = UIButton() 56 | userNameButton.addTarget(self, action: #selector(showUser), for: .touchUpInside) 57 | userNameButton.contentEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: .leastNormalMagnitude) 58 | userNameButton.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) 59 | userNameButton.setContentHuggingPriority(.defaultHigh, for: .horizontal) 60 | userNameButton.setTitleColor(tintColor, for: .normal) 61 | userNameButton.setTitleColor(tintColor.withAlphaComponent(0.2), for: .highlighted) 62 | userNameButton.titleLabel?.font = .preferredFont(forTextStyle: .body) 63 | detailStackView.addArrangedSubview(userNameButton) 64 | 65 | createdAtLabel = UILabel() 66 | createdAtLabel.font = .preferredFont(forTextStyle: .subheadline) 67 | createdAtLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) 68 | createdAtLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) 69 | createdAtLabel.textColor = .secondaryLabel 70 | detailStackView.addArrangedSubview(createdAtLabel) 71 | 72 | indexButton = UIButton() 73 | indexButton.addTarget(self, action: #selector(showActions), for: .touchUpInside) 74 | indexButton.contentEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: .leastNormalMagnitude) 75 | indexButton.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) 76 | indexButton.setContentHuggingPriority(.defaultHigh, for: .horizontal) 77 | indexButton.setTitleColor(tintColor, for: .normal) 78 | indexButton.setTitleColor(tintColor.withAlphaComponent(0.2), for: .highlighted) 79 | indexButton.titleLabel?.font = .preferredFont(forTextStyle: .body) 80 | detailStackView.addArrangedSubview(indexButton) 81 | 82 | stackView.addArrangedSubview(webView) 83 | } 84 | 85 | override func tintColorDidChange() { 86 | super.tintColorDidChange() 87 | 88 | userNameButton.setTitleColor(tintColor, for: .normal) 89 | indexButton.setTitleColor(tintColor, for: .normal) 90 | } 91 | 92 | private func didSetReply() { 93 | bodyHTML = reply?.bodyHTML 94 | createdAtLabel.text = reply?.createdAt 95 | indexButton.setTitle("#\((reply?.index ?? 0) + 1)", for: .normal) 96 | userAvatarView.setImage(withURL: reply?.user?.avatar) 97 | userNameButton.setTitle(reply?.user?.name, for: .normal) 98 | } 99 | 100 | @objc 101 | private func showUser() { 102 | let userController = UserController() 103 | userController.user = reply?.user 104 | viewController?.navigationController?.pushViewController(userController, animated: true) 105 | } 106 | 107 | @objc 108 | private func showActions(_ button: UIButton) { 109 | let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) 110 | alertController.addAction(UIAlertAction(title: "回复", style: .default) { _ in 111 | (self.viewController as? TopicController)?.showCompose(button, self.reply) 112 | }) 113 | alertController.addAction(UIAlertAction(title: "举报", style: .destructive) { _ in 114 | (self.viewController as? TopicController)?.report() 115 | }) 116 | alertController.addAction(UIAlertAction(title: "取消", style: .cancel)) 117 | alertController.popoverPresentationController?.sourceRect = button.bounds 118 | alertController.popoverPresentationController?.sourceView = button 119 | viewController?.present(alertController, animated: true) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /V2EX/Classes/Views/Topic/TopicWebCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TopicWebCell.swift 3 | // V2EX 4 | // 5 | // Created by Jianqiu Xiao on 4/10/19. 6 | // Copyright © 2019 Jianqiu Xiao. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import WebKit 11 | 12 | class TopicWebCell: UITableViewCell { 13 | 14 | public var bodyHTML: String? { didSet { didSetBodyHTML() } } 15 | public var webView: UIWebView! 16 | private var webViewObservation: NSKeyValueObservation? 17 | 18 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 19 | super.init(style: style, reuseIdentifier: reuseIdentifier) 20 | 21 | selectionStyle = .none 22 | 23 | webView = UIWebView() 24 | webView.backgroundColor = .clear 25 | webView.dataDetectorTypes = [] 26 | webView.delegate = self 27 | webView.isOpaque = false 28 | webView.scrollView.isScrollEnabled = false 29 | webView.snp.makeConstraints { $0.height.equalTo(Int(UIFont.preferredFont(forTextStyle: .body).pointSize)).priority(999) } 30 | 31 | webViewObservation = webView.observe(\.scrollView.contentSize) { webView, _ in 32 | webView.snp.updateConstraints { $0.height.equalTo(webView.scrollView.contentSize.height).priority(999) } 33 | let tableView = self.next(of: UITableView.self) 34 | guard let indexPath = tableView?.indexPath(for: self) else { return } 35 | tableView?.reloadRows(at: [indexPath], with: .none) 36 | } 37 | } 38 | 39 | @available(*, unavailable) 40 | required init?(coder aDecoder: NSCoder) { 41 | fatalError("init(coder:) has not been implemented") 42 | } 43 | 44 | private func didSetBodyHTML() { 45 | guard let htmlString = htmlString, webView.request == nil else { return } 46 | webView.loadHTMLString(htmlString, baseURL: viewController?.baseURL) 47 | } 48 | 49 | private var htmlString: String? { 50 | guard let bodyHTML = bodyHTML else { return nil } 51 | let style = """ 52 | *, *::before, *::after { box-sizing: border-box; padding: 0; margin: 0; } 53 | :root { color-scheme: light dark; } 54 | html, body { width: 100%; height: 100%; font-family: -apple-system; font: -apple-system-body; word-wrap: break-word; -webkit-tap-highlight-color: transparent; } 55 | h1, h2, h3, h4, h5, h6 { margin: 1.5em 0 1em; font: -apple-system-title3; } 56 | ol, ul { padding-left: 1.5em; margin: 1em 0; } 57 | ol { list-style-type: decimal; } 58 | ul { list-style-type: disc; } 59 | li { margin: 0.5em 0; } 60 | hr { margin: 1em 0; border: none; border-bottom: 0.5px solid #c6c6c8; } 61 | @media (prefers-color-scheme: dark) { hr { border-bottom-color: #38383a; } } 62 | pre { padding: 0.5em; margin: 1em 0; font: -apple-system-footnote; white-space: pre-wrap; background-color: #f2f2f7; } 63 | @media (prefers-color-scheme: dark) { pre { background-color: #1c1c1e; } } 64 | p { margin: 0.5em 0; } 65 | a { color: #007aff; text-decoration: none; } 66 | @media (prefers-color-scheme: dark) { a { color: #0a84ff; } } 67 | img { max-width: 100%; } 68 | button { display: none; } 69 | body :first-child { margin-top: 0; } 70 | body :last-child { margin-bottom: 0; } 71 | """ 72 | let script = """ 73 | document.addEventListener('DOMContentLoaded', function(event) { 74 | var images = document.querySelectorAll('img:not(.emoji):not(.twemoji)') 75 | for (var i = 0; i < images.length; i++) { 76 | var image = images[i] 77 | var a = document.createElement('a') 78 | a.href = image.src + '#imageview' 79 | image.parentElement.insertBefore(a, image) 80 | a.appendChild(image) 81 | } 82 | }) 83 | """ 84 | return """ 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | \(bodyHTML) 93 | 94 | """ 95 | } 96 | } 97 | 98 | extension TopicWebCell: UIWebViewDelegate { 99 | 100 | func webView(_ webView: UIWebView, shouldStartLoadWith request: URLRequest, navigationType: UIWebView.NavigationType) -> Bool { 101 | guard navigationType == .linkClicked, let url = request.url else { return true } 102 | if url.scheme == "applewebdata" || url.host == "www.v2ex.com" { 103 | if let name = "^/member/([^/]+)$".r?.findFirst(in: url.path)?.group(at: 1) { 104 | let userController = UserController() 105 | userController.user = try? User(json: ["name": name]) 106 | viewController?.navigationController?.pushViewController(userController, animated: true) 107 | } else if let id = "^/t/(\\d+)$".r?.findFirst(in: url.path)?.group(at: 1) { 108 | let topicController = TopicController() 109 | topicController.topic = try? Topic(json: ["id": Int(id)]) 110 | viewController?.navigationController?.pushViewController(topicController, animated: true) 111 | } else { 112 | let webViewController = WebViewController() 113 | webViewController.url = viewController?.baseURL.appendingPathComponent(url.path) 114 | viewController?.navigationController?.pushViewController(webViewController, animated: true) 115 | } 116 | } else if WKWebView.handlesURLScheme(url.scheme ?? "") { 117 | let webViewController = WebViewController() 118 | webViewController.title = url.fragment == "imageview" ? "图片" : nil 119 | webViewController.url = url 120 | viewController?.navigationController?.pushViewController(webViewController, animated: true) 121 | } else if UIApplication.shared.canOpenURL(url) { 122 | UIApplication.shared.open(url) 123 | } 124 | return false 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /V2EX/Classes/Controllers/SignInController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SignInController.swift 3 | // V2EX 4 | // 5 | // Created by Jianqiu Xiao on 4/17/19. 6 | // Copyright © 2019 Jianqiu Xiao. All rights reserved. 7 | // 8 | 9 | import Alamofire 10 | import Kanna 11 | import KeychainAccess 12 | 13 | class SignInController: ViewController { 14 | 15 | private var activityIndicatorView: ActivityIndicatorView! 16 | private var isRefreshing = false { didSet { didSetRefreshing() } } 17 | private var networkErrorView: NetworkErrorView! 18 | public var session: Session? 19 | private var tableView: UITableView! 20 | private var termsSwitch: UISwitch! 21 | 22 | override init() { 23 | super.init() 24 | 25 | navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(dismiss)) 26 | navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .action, target: self, action: #selector(action)) 27 | 28 | title = "登录" 29 | } 30 | 31 | override func loadView() { 32 | tableView = UITableView(frame: .zero, style: .grouped) 33 | tableView.cellLayoutMarginsFollowReadableWidth = true 34 | tableView.dataSource = self 35 | tableView.delegate = self 36 | tableView.register(SignInCaptchaCell.self, forCellReuseIdentifier: SignInCaptchaCell.description()) 37 | tableView.register(SignInPasswordCell.self, forCellReuseIdentifier: SignInPasswordCell.description()) 38 | tableView.register(SignInUsernameCell.self, forCellReuseIdentifier: SignInUsernameCell.description()) 39 | tableView.register(UITableViewCell.self, forCellReuseIdentifier: UITableViewCell.description()) 40 | view = tableView 41 | 42 | activityIndicatorView = ActivityIndicatorView() 43 | view.addSubview(activityIndicatorView) 44 | 45 | networkErrorView = NetworkErrorView() 46 | networkErrorView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(fetchData))) 47 | view.addSubview(networkErrorView) 48 | 49 | termsSwitch = UISwitch() 50 | termsSwitch.addTarget(tableView, action: #selector(tableView.reloadData), for: .valueChanged) 51 | termsSwitch.isOn = true 52 | } 53 | 54 | override func viewWillAppear(_ animated: Bool) { 55 | super.viewWillAppear(animated) 56 | 57 | tableView.indexPathsForSelectedRows?.forEach { tableView.deselectRow(at: $0, animated: animated) } 58 | 59 | fetchData() 60 | } 61 | 62 | @objc 63 | internal func fetchData() { 64 | if isRefreshing { return } 65 | isRefreshing = true 66 | (tableView.cellForRow(at: IndexPath(row: 0, section: 1)) as? SignInCaptchaCell)?.captchaField.text = nil 67 | AF.request( 68 | baseURL.appendingPathComponent("signin") 69 | ).responseString { response in 70 | if 200..<300 ~= response.response?.statusCode ?? 0 { 71 | let doc = try? HTML(html: response.value ?? "", encoding: .utf8) 72 | if let message = doc?.at_css(".topic_content p")?.text { 73 | self.view.endEditing(true) 74 | let alertController = UIAlertController(title: "登录受限", message: message, preferredStyle: .alert) 75 | alertController.addAction(UIAlertAction(title: "好", style: .default) { _ in 76 | self.dismiss(animated: true) 77 | }) 78 | self.present(alertController, animated: true) 79 | } else { 80 | let json = [ 81 | "usernameKey": doc?.at_css("#Main form tr:nth-child(1) input")?["name"], 82 | "passwordKey": doc?.at_css("#Main form tr:nth-child(2) input")?["name"], 83 | "captchaKey": doc?.at_css("#Main form tr:nth-child(3) input")?["name"], 84 | "once": "once=(\\d+)".r?.findFirst(in: doc?.at_css("#Main form tr:nth-child(3) td:nth-child(2) div[style]")?["style"] ?? "")?.group(at: 1), 85 | "username": self.session?.username, 86 | "password": self.session?.password, 87 | ] 88 | self.session = try? Session(json: json) 89 | self.tableView.reloadSections([1], with: .none) 90 | } 91 | } else { 92 | self.networkErrorView.isHidden = false 93 | } 94 | self.isRefreshing = false 95 | } 96 | } 97 | 98 | private func didSetRefreshing() { 99 | if isRefreshing { 100 | networkErrorView.isHidden = true 101 | activityIndicatorView.startAnimating() 102 | } else { 103 | activityIndicatorView.stopAnimating() 104 | } 105 | } 106 | 107 | @objc 108 | private func action() { 109 | view.endEditing(true) 110 | let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) 111 | [("注册", "signup"), ("忘记密码", "forgot")].forEach { title, pathComponent in 112 | alertController.addAction(UIAlertAction(title: title, style: .default) { _ in 113 | let webViewController = WebViewController() 114 | webViewController.title = title 115 | webViewController.url = self.baseURL.appendingPathComponent(pathComponent) 116 | self.navigationController?.pushViewController(webViewController, animated: true) 117 | }) 118 | } 119 | alertController.addAction(UIAlertAction(title: "取消", style: .cancel)) 120 | present(alertController, animated: true) 121 | } 122 | 123 | internal func signIn() { 124 | guard let session = session else { return } 125 | view.endEditing(true) 126 | showHUD() 127 | AF.request( 128 | baseURL.appendingPathComponent("signin"), 129 | method: .post, 130 | parameters: [ 131 | session.usernameKey: session.username ?? "", 132 | session.passwordKey: session.password ?? "", 133 | session.captchaKey: session.captcha ?? "", 134 | "once": session.once, 135 | "next": "/", 136 | ], 137 | headers: [ 138 | "Referer": baseURL.appendingPathComponent("signin").absoluteString, 139 | ] 140 | ) 141 | .responseString { response in 142 | if 200..<300 ~= response.response?.statusCode ?? 0 { 143 | let doc = try? HTML(html: response.value ?? "", encoding: .utf8) 144 | if let title = doc?.at_css(".problem ul li")?.text { 145 | self.fetchData() 146 | let alertController = UIAlertController(title: title, message: nil, preferredStyle: .alert) 147 | alertController.addAction(UIAlertAction(title: "好", style: .default)) 148 | self.present(alertController, animated: true) 149 | } else { 150 | DispatchQueue.global().async { 151 | let session = Session(usernameKey: "", passwordKey: "", captchaKey: "", once: "", username: session.username, password: session.password, captcha: nil) 152 | try? Keychain(service: "com.v2ex.www").accessibility(.whenPasscodeSetThisDeviceOnly, authenticationPolicy: .userPresence).set(session.toData(), key: "session") 153 | DispatchQueue.main.async { 154 | (((self.presentingViewController as? UITabBarController)?.viewControllers?.first as? UINavigationController)?.viewControllers.first as? TopicsController)?.signed() 155 | self.dismiss(animated: true) 156 | } 157 | } 158 | } 159 | } else { 160 | self.networkError() 161 | } 162 | self.hideHUD() 163 | } 164 | } 165 | } 166 | 167 | extension SignInController: UITableViewDataSource { 168 | 169 | func numberOfSections(in tableView: UITableView) -> Int { 170 | return 4 171 | } 172 | 173 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 174 | return [2, 1, 1, 1][section] 175 | } 176 | 177 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 178 | switch (indexPath.section, indexPath.row) { 179 | case (0, 0): 180 | let cell = tableView.dequeueReusableCell(withIdentifier: SignInUsernameCell.description(), for: indexPath) as? SignInUsernameCell ?? .init() 181 | cell.usernameField.text = session?.username 182 | return cell 183 | 184 | case (0, 1): 185 | let cell = tableView.dequeueReusableCell(withIdentifier: SignInPasswordCell.description(), for: indexPath) as? SignInPasswordCell ?? .init() 186 | cell.passwordField.text = session?.password 187 | return cell 188 | 189 | case (1, 0): 190 | let cell = tableView.dequeueReusableCell(withIdentifier: SignInCaptchaCell.description(), for: indexPath) as? SignInCaptchaCell ?? .init() 191 | cell.captchaField.text = session?.captcha 192 | if let once = session?.once { 193 | let url = URL(string: "\(baseURL.absoluteString)/_captcha?once=\(once)") 194 | cell.captchaImageView.setImage(withURL: url) 195 | } 196 | return cell 197 | 198 | case (2, 0): 199 | let cell = tableView.dequeueReusableCell(withIdentifier: UITableViewCell.description(), for: indexPath) 200 | cell.accessoryView = termsSwitch 201 | cell.backgroundColor = .clear 202 | cell.textLabel?.text = "同意最终用户许可协议" 203 | cell.textLabel?.textColor = tableView.tintColor 204 | return cell 205 | 206 | case (3, 0): 207 | let cell = tableView.dequeueReusableCell(withIdentifier: UITableViewCell.description(), for: indexPath) 208 | cell.selectionStyle = termsSwitch.isOn ? .default : .none 209 | cell.textLabel?.text = "登录" 210 | cell.textLabel?.textColor = termsSwitch.isOn ? tableView.tintColor : .tertiaryLabel 211 | return cell 212 | 213 | default: 214 | return .init() 215 | } 216 | } 217 | } 218 | 219 | extension SignInController: UITableViewDelegate { 220 | 221 | func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 222 | switch (indexPath.section, indexPath.row) { 223 | case (2, 0): 224 | let webViewController = WebViewController() 225 | webViewController.title = "最终用户许可协议" 226 | webViewController.url = baseURL.appendingPathComponent("about") 227 | navigationController?.pushViewController(webViewController, animated: true) 228 | 229 | case (3, 0): 230 | tableView.deselectRow(at: indexPath, animated: true) 231 | if termsSwitch.isOn { signIn() } 232 | 233 | default: 234 | break 235 | } 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /V2EX/Classes/Controllers/UserController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserController.swift 3 | // V2EX 4 | // 5 | // Created by Jianqiu Xiao on 4/16/19. 6 | // Copyright © 2019 Jianqiu Xiao. All rights reserved. 7 | // 8 | 9 | import Alamofire 10 | import Kanna 11 | 12 | class UserController: ViewController { 13 | 14 | private var activityIndicatorView: ActivityIndicatorView! 15 | private var isRefreshing = false { didSet { didSetRefreshing() } } 16 | private var networkErrorView: NetworkErrorView! 17 | private var noContentView: NoContentView! 18 | private var tableView: UITableView! 19 | public var user: User? { didSet { didSetUser() } } 20 | 21 | override init() { 22 | super.init() 23 | 24 | navigationItem.largeTitleDisplayMode = .never 25 | } 26 | 27 | override func loadView() { 28 | tableView = UITableView(frame: .zero, style: .grouped) 29 | tableView.cellLayoutMarginsFollowReadableWidth = true 30 | tableView.dataSource = self 31 | tableView.delegate = self 32 | tableView.refreshControl = UIRefreshControl() 33 | tableView.refreshControl?.addTarget(self, action: #selector(fetchData), for: .valueChanged) 34 | tableView.register(TopicsCell.self, forCellReuseIdentifier: TopicsCell.description()) 35 | tableView.register(UserHeaderView.self, forHeaderFooterViewReuseIdentifier: UserHeaderView.description()) 36 | view = tableView 37 | 38 | activityIndicatorView = ActivityIndicatorView() 39 | view.addSubview(activityIndicatorView) 40 | 41 | networkErrorView = NetworkErrorView() 42 | networkErrorView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(fetchData))) 43 | view.addSubview(networkErrorView) 44 | 45 | noContentView = NoContentView() 46 | noContentView.textLabel?.text = "无内容" 47 | view.addSubview(noContentView) 48 | } 49 | 50 | override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { 51 | super.traitCollectionDidChange(previousTraitCollection) 52 | 53 | additionalSafeAreaInsets.top = -(navigationController?.navigationBar.intrinsicContentSize.height ?? 0) 54 | 55 | tryUpdateNavigationBarBackground() 56 | } 57 | 58 | override func viewWillAppear(_ animated: Bool) { 59 | super.viewWillAppear(animated) 60 | 61 | additionalSafeAreaInsets.top = -(navigationController?.navigationBar.intrinsicContentSize.height ?? 0) 62 | 63 | tableView.indexPathsForSelectedRows?.forEach { tableView.deselectRow(at: $0, animated: animated) } 64 | 65 | tryUpdateNavigationBarBackground() 66 | 67 | if user?.topics == nil || !networkErrorView.isHidden { fetchData() } 68 | } 69 | 70 | override func viewDidAppear(_ animated: Bool) { 71 | super.viewDidAppear(animated) 72 | 73 | tryUpdateNavigationBarBackground() 74 | } 75 | 76 | override func viewWillDisappear(_ animated: Bool) { 77 | super.viewWillDisappear(animated) 78 | 79 | updateNavigationBarBackground() 80 | } 81 | 82 | @objc 83 | private func fetchData() { 84 | if isRefreshing { return } 85 | isRefreshing = true 86 | AF.request( 87 | baseURL 88 | .appendingPathComponent("member") 89 | .appendingPathComponent(user?.name ?? "") 90 | ) 91 | .responseString { response in 92 | if 200..<300 ~= response.response?.statusCode ?? 0 { 93 | let doc = try? HTML(html: response.value ?? "", encoding: .utf8) 94 | let json = [ 95 | "id": Int("第 (\\d+) 号会员".r?.findFirst(in: doc?.at_css("#Main .box:nth-child(2) .gray")?.text ?? "")?.group(at: 1) ?? ""), 96 | "name": self.user?.name, 97 | "avatar": doc?.at_css(".avatar")?["src"], 98 | "isBlocked": doc?.at_css(".super.normal.button")?["value"] == "Unblock", 99 | "token": "t=(\\d+)".r?.findFirst(in: doc?.at_css(".super.normal.button")?["onclick"] ?? "")?.group(at: 1), 100 | "once": self.user?.once, 101 | "createdAt": "\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2} \\+\\d{2}:\\d{2}".r?.findFirst(in: doc?.at_css("#Main .box:nth-child(2) .gray")?.text ?? "")?.matched, 102 | "topics": doc?.css(".box .cell.item").map { 103 | [ 104 | "id": Int("^/t/(\\d+)".r?.findFirst(in: $0.at_css(".item_title a")?["href"] ?? "")?.group(at: 1) ?? ""), 105 | "name": $0.at_css(".item_title a")?.text, 106 | "repliesCount": Int($0.at_css(".count_livid")?.text ?? ""), 107 | "repliedAt": "[^•]+前|刚刚|\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}".r?.findFirst(in: $0.at_css(".topic_info, .small")?.text ?? "")?.matched.trimmingCharacters(in: .whitespaces), 108 | "node": [ 109 | "name": $0.at_css(".node")?.text, 110 | "code": "[^/]+".r?.findFirst(in: $0.at_css(".node")?["href"] ?? "")?.matched, 111 | ], 112 | ] as [String: Any?] 113 | }, 114 | ] as [String: Any?] 115 | self.user = try? User(json: json) 116 | self.noContentView.isHidden = self.user?.topics?.count ?? 0 > 0 117 | self.tableView.reloadData() 118 | } else { 119 | self.networkErrorView.isHidden = false 120 | } 121 | self.isRefreshing = false 122 | } 123 | } 124 | 125 | private func didSetRefreshing() { 126 | if isRefreshing { 127 | networkErrorView.isHidden = true 128 | noContentView.isHidden = true 129 | if tableView.refreshControl?.isRefreshing ?? false { return } 130 | activityIndicatorView.startAnimating() 131 | } else { 132 | tableView.refreshControl?.endRefreshing() 133 | activityIndicatorView.stopAnimating() 134 | } 135 | } 136 | 137 | private func didSetUser() { 138 | navigationItem.rightBarButtonItem = user?.once != nil ? UIBarButtonItem(title: "退出登录", style: .plain, target: self, action: #selector(signOut)) : UIBarButtonItem(title: user?.isBlocked == nil ? nil : user?.isBlocked ?? false ? "解除屏蔽" : "屏蔽", style: .plain, target: self, action: #selector(toggleBlock)) 139 | 140 | userActivity?.webpageURL = baseURL 141 | .appendingPathComponent("member") 142 | .appendingPathComponent(user?.name ?? "") 143 | } 144 | 145 | @objc 146 | private func signOut(_ barButtonItem: UIBarButtonItem) { 147 | let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) 148 | alertController.addAction(UIAlertAction(title: "退出登录", style: .destructive) { _ in 149 | self.showHUD() 150 | AF.request( 151 | self.baseURL.appendingPathComponent("signout"), 152 | parameters: [ 153 | "once": self.user?.once ?? "", 154 | ], 155 | headers: [ 156 | "Referer": self.baseURL.absoluteString, 157 | ] 158 | ) 159 | .responseString { response in 160 | self.hideHUD() 161 | if 200..<300 ~= response.response?.statusCode ?? 0 { 162 | let navigationController = self.navigationController 163 | navigationController?.popViewController(animated: true) 164 | (navigationController?.viewControllers.first as? TopicsController)?.signed() 165 | } else { 166 | self.networkError() 167 | } 168 | } 169 | }) 170 | alertController.addAction(UIAlertAction(title: "取消", style: .cancel)) 171 | alertController.popoverPresentationController?.barButtonItem = barButtonItem 172 | present(alertController, animated: true) 173 | } 174 | 175 | @objc 176 | private func toggleBlock(_ barButtonItem: UIBarButtonItem) { 177 | if ((tabBarController?.viewControllers?.first as? UINavigationController)?.viewControllers.first as? TopicsController)?.user?.once == nil { return signInRequired(barButtonItem) } 178 | let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) 179 | alertController.addAction(UIAlertAction(title: user?.isBlocked ?? false ? "解除屏蔽" : "屏蔽", style: .destructive) { _ in 180 | self.showHUD() 181 | AF.request( 182 | self.baseURL 183 | .appendingPathComponent(self.user?.isBlocked ?? false ? "unblock" : "block") 184 | .appendingPathComponent(String(self.user?.id ?? 0)), 185 | parameters: [ 186 | "t": self.user?.token ?? "", 187 | ] 188 | ) 189 | .responseString { response in 190 | if 200..<300 ~= response.response?.statusCode ?? 0 { 191 | self.navigationController?.popViewController(animated: true) 192 | } else { 193 | self.networkError() 194 | } 195 | self.hideHUD() 196 | } 197 | }) 198 | alertController.addAction(UIAlertAction(title: "取消", style: .cancel)) 199 | alertController.popoverPresentationController?.barButtonItem = barButtonItem 200 | present(alertController, animated: true) 201 | } 202 | 203 | private func updateNavigationBarBackground() { 204 | guard let navigationBar = navigationController?.navigationBar else { return } 205 | let headerView = tableView.headerView(forSection: 0) 206 | let alpha = self != navigationController?.topViewController || headerView == nil ? 1 : (tableView.contentOffset.y + view.safeAreaInsets.top) / ((headerView?.frame.height ?? 0) - navigationBar.frame.height) 207 | navigationBar.subviews.first { $0.classForCoder.description() == "_UIBarBackground" }?.alpha = max(0, min(1, alpha)) 208 | navigationItem.title = alpha < 1 ? nil : user?.name 209 | } 210 | 211 | private func tryUpdateNavigationBarBackground() { 212 | for interval in [0.001, 0.01, 0.1] { 213 | DispatchQueue.main.asyncAfter(deadline: .now() + interval) { 214 | self.updateNavigationBarBackground() 215 | } 216 | } 217 | } 218 | } 219 | 220 | extension UserController: UIScrollViewDelegate { 221 | 222 | func scrollViewDidScroll(_ scrollView: UIScrollView) { 223 | if self != navigationController?.topViewController { return } 224 | updateNavigationBarBackground() 225 | } 226 | } 227 | 228 | extension UserController: UITableViewDataSource { 229 | 230 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 231 | return user?.topics?.count ?? 0 232 | } 233 | 234 | func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { 235 | let view = tableView.dequeueReusableHeaderFooterView(withIdentifier: UserHeaderView.description()) as? UserHeaderView 236 | view?.user = user 237 | return view 238 | } 239 | 240 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 241 | let cell = tableView.dequeueReusableCell(withIdentifier: TopicsCell.description(), for: indexPath) as? TopicsCell ?? .init() 242 | cell.tableViewStyle = tableView.style 243 | cell.topic = user?.topics?[indexPath.row] 244 | return cell 245 | } 246 | } 247 | 248 | extension UserController: UITableViewDelegate { 249 | 250 | func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 251 | let topicController = TopicController() 252 | topicController.topic = user?.topics?[indexPath.row] 253 | topicController.topic?.user = user 254 | navigationController?.pushViewController(topicController, animated: true) 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /V2EX/Classes/Controllers/TopicsController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TopicsController.swift 3 | // V2EX 4 | // 5 | // Created by Jianqiu Xiao on 4/7/19. 6 | // Copyright © 2019 Jianqiu Xiao. All rights reserved. 7 | // 8 | 9 | import Alamofire 10 | import Kanna 11 | import KeychainAccess 12 | 13 | class TopicsController: ViewController { 14 | 15 | private var activityIndicatorView: ActivityIndicatorView! 16 | private var isRefreshing = false { didSet { didSetRefreshing() } } 17 | private var networkErrorView: NetworkErrorView! 18 | public var node = Node.all { didSet { didSetNode() } } 19 | private var nodesView: TopicsNodesView? 20 | private var noContentView: NoContentView! 21 | private var tableView: UITableView! 22 | private var topics: [Topic] = [] 23 | private var topicsIsLoaded = false 24 | private var topicsNextPage = 1 25 | public var user: User? { didSet { didSetUser() } } 26 | 27 | override func loadView() { 28 | tableView = UITableView() 29 | tableView.cellLayoutMarginsFollowReadableWidth = true 30 | tableView.dataSource = self 31 | tableView.delegate = self 32 | tableView.refreshControl = UIRefreshControl() 33 | tableView.refreshControl?.addTarget(self, action: #selector(fetchData), for: .valueChanged) 34 | tableView.register(TopicsCell.self, forCellReuseIdentifier: TopicsCell.description()) 35 | tableView.register(UITableViewCell.self, forCellReuseIdentifier: UITableViewCell.description()) 36 | tableView.tableFooterView = UIView() 37 | tableView.verticalScrollIndicatorInsets.top = self == navigationController?.viewControllers.first ? 44 : 0 38 | view = tableView 39 | 40 | activityIndicatorView = ActivityIndicatorView() 41 | view.addSubview(activityIndicatorView) 42 | 43 | networkErrorView = NetworkErrorView() 44 | networkErrorView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(fetchData))) 45 | view.addSubview(networkErrorView) 46 | 47 | noContentView = NoContentView() 48 | noContentView.textLabel?.text = "无内容" 49 | view.addSubview(noContentView) 50 | 51 | nodesView = self == navigationController?.viewControllers.first ? TopicsNodesView() : nil 52 | tableView.tableHeaderView = nodesView 53 | nodesView?.snp.makeConstraints { make in 54 | make.width.equalToSuperview() 55 | make.height.equalTo(44) 56 | } 57 | } 58 | 59 | override func viewSafeAreaInsetsDidChange() { 60 | super.viewSafeAreaInsetsDidChange() 61 | 62 | nodesView?.contentInset.left = view.layoutMargins.left - view.safeAreaInsets.left 63 | nodesView?.contentInset.right = view.layoutMargins.right - view.safeAreaInsets.right 64 | } 65 | 66 | override func viewLayoutMarginsDidChange() { 67 | super.viewLayoutMarginsDidChange() 68 | 69 | nodesView?.contentInset.left = view.layoutMargins.left - view.safeAreaInsets.left 70 | nodesView?.contentInset.right = view.layoutMargins.right - view.safeAreaInsets.right 71 | } 72 | 73 | override func viewWillAppear(_ animated: Bool) { 74 | super.viewWillAppear(animated) 75 | 76 | title = self == navigationController?.viewControllers.first ? Bundle.main.infoDictionary?[kCFBundleNameKey as String] as? String : node.name 77 | 78 | tableView.indexPathsForSelectedRows?.forEach { tableView.deselectRow(at: $0, animated: animated) } 79 | 80 | if topics.count == 0 && !topicsIsLoaded || !networkErrorView.isHidden { fetchData() } 81 | } 82 | 83 | @objc 84 | func fetchData() { 85 | if activityIndicatorView.isAnimating { tableView.refreshControl?.endRefreshing() } 86 | if isRefreshing { return } 87 | isRefreshing = true 88 | AF.request( 89 | self != navigationController?.viewControllers.first ? baseURL.appendingPathComponent("go").appendingPathComponent(node.code ?? "") : node.code == Node.all.code && topics.count > 0 && !(tableView.refreshControl?.isRefreshing ?? false) ? baseURL.appendingPathComponent("recent") : baseURL, 90 | parameters: [ 91 | "p": tableView.refreshControl?.isRefreshing ?? false ? 1 : topicsNextPage, 92 | "tab": node.code ?? "", 93 | ] 94 | ) 95 | .responseString { response in 96 | if self.tableView.refreshControl?.isRefreshing ?? false { 97 | self.topics = [] 98 | self.topicsIsLoaded = false 99 | } 100 | if 200..<300 ~= response.response?.statusCode ?? 0 { 101 | let doc = try? HTML(html: response.value ?? "", encoding: .utf8) 102 | if self == self.navigationController?.viewControllers.first { 103 | let userJSON = [ 104 | "name": doc?.at_css("#Top .tools a:nth-child(2)")?.text, 105 | "avatar": doc?.at_css("#Rightbar .box:nth-child(2) .avatar")?["src"], 106 | "once": "\\d{5}".r?.findFirst(in: doc?.at_css("#Top .tools a:last-child")?["onclick"] ?? "")?.matched, 107 | ] 108 | self.user = try? User(json: userJSON) 109 | } 110 | if self.nodesView != nil && self.nodesView?.nodes == nil { 111 | let nodesJSON = doc?.css("#Tabs a").map { 112 | [ 113 | "name": $0.text, 114 | "code": "[^=]+$".r?.findFirst(in: $0["href"] ?? "")?.matched, 115 | ] 116 | } 117 | self.nodesView?.nodes = (try? [Node](json: nodesJSON ?? []))?.filter { $0.code != Node.all.code } 118 | } 119 | self.topicsIsLoaded = doc?.at_css(".normal_page_right:not(.disable_now)") == nil && (self.node.code != Node.all.code || self.topics.count > 0 && !(self.tableView.refreshControl?.isRefreshing ?? false) || self.user?.once == nil) 120 | self.topicsNextPage = Int(doc?.at_css(".page_current + a")?.text ?? "") ?? 1 121 | let json = doc?.css("#Main .cell.item, #TopicsNode .cell").map { 122 | [ 123 | "id": Int("^/t/(\\d+)".r?.findFirst(in: $0.at_css(".item_title a")?["href"] ?? "")?.group(at: 1) ?? ""), 124 | "name": $0.at_css(".item_title a")?.text, 125 | "repliesCount": Int($0.at_css(".count_livid")?.text ?? ""), 126 | "repliedAt": "[^•]+前|刚刚|\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}".r?.findFirst(in: ($0.at_css(".topic_info") ?? $0.at_css(".small"))?.text ?? "")?.matched.trimmingCharacters(in: .whitespaces), 127 | "isSticky": "corner_star".r?.matches($0["style"] ?? ""), 128 | "user": [ 129 | "name": $0.at_css("strong")?.text, 130 | "avatar": $0.at_css("img")?["src"], 131 | ], 132 | "node": [ 133 | "name": $0.at_css(".node")?.text, 134 | "code": "[^/]+".r?.findFirst(in: $0.at_css(".node")?["href"] ?? "")?.matched, 135 | ], 136 | ] as [String: Any?] 137 | } 138 | self.topics += (try? [Topic](json: json ?? [])) ?? [] 139 | self.noContentView.isHidden = self.topics.count > 0 140 | } else { 141 | self.networkErrorView.isHidden = false 142 | DispatchQueue.main.asyncAfter(deadline: .now() + 1) { 143 | self.fetchData() 144 | } 145 | } 146 | self.tableView.reloadData() 147 | self.isRefreshing = false 148 | } 149 | } 150 | 151 | private func didSetRefreshing() { 152 | if isRefreshing { 153 | networkErrorView.isHidden = true 154 | noContentView.isHidden = true 155 | nodesView?.isEnabled = false 156 | if tableView.refreshControl?.isRefreshing ?? false { return } 157 | activityIndicatorView.startAnimating() 158 | } else { 159 | nodesView?.isEnabled = true 160 | tableView.refreshControl?.endRefreshing() 161 | activityIndicatorView.stopAnimating() 162 | } 163 | } 164 | 165 | private func didSetNode() { 166 | userActivity?.webpageURL = self == navigationController?.viewControllers.first ? baseURL : baseURL.appendingPathComponent("go").appendingPathComponent(node.code ?? "") 167 | 168 | if isViewLoaded { refetchData() } 169 | } 170 | 171 | private func didSetUser() { 172 | if user == nil { 173 | navigationItem.rightBarButtonItem = nil 174 | } else if user?.once == nil { 175 | navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "person.crop.circle"), style: .plain, target: self, action: #selector(showSignIn)) 176 | } else { 177 | let imageView = UIImageView() 178 | imageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showUser))) 179 | imageView.backgroundColor = .secondarySystemBackground 180 | imageView.clipsToBounds = true 181 | imageView.isUserInteractionEnabled = true 182 | imageView.layer.cornerRadius = 14 183 | imageView.setImage(withURL: user?.avatar) 184 | imageView.snp.makeConstraints { $0.size.equalTo(28) } 185 | navigationItem.rightBarButtonItem = UIBarButtonItem(customView: imageView) 186 | } 187 | } 188 | 189 | private func refetchData() { 190 | topics = [] 191 | topicsIsLoaded = false 192 | topicsNextPage = 1 193 | tableView.reloadData() 194 | fetchData() 195 | } 196 | 197 | internal func signed() { 198 | user = nil 199 | refetchData() 200 | } 201 | 202 | @objc 203 | private func showUser() { 204 | let userController = UserController() 205 | userController.user = user 206 | navigationController?.pushViewController(userController, animated: true) 207 | } 208 | 209 | @objc 210 | internal func showSignIn(_ sender: Any? = nil) { 211 | DispatchQueue.global().async { 212 | let data = (try? Keychain(service: "com.v2ex.www").getData("session")) ?? .init() 213 | DispatchQueue.main.async { 214 | let signInController = SignInController() 215 | signInController.session = try? Session(data: data) 216 | let navigationController = UINavigationController(rootViewController: signInController) 217 | navigationController.modalPresentationStyle = sender is UIBarButtonItem ? .popover : .formSheet 218 | navigationController.popoverPresentationController?.barButtonItem = sender as? UIBarButtonItem 219 | self.present(navigationController, animated: true) 220 | } 221 | } 222 | } 223 | } 224 | 225 | extension TopicsController: UITableViewDataSource { 226 | 227 | func numberOfSections(in tableView: UITableView) -> Int { 228 | return 2 229 | } 230 | 231 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 232 | return [ 233 | topics.count, 234 | node.code == Node.all.code && user?.once == nil && topics.count > 0 ? 1 : 0, 235 | ][section] 236 | } 237 | 238 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 239 | switch indexPath.section { 240 | case 0: 241 | let cell = tableView.dequeueReusableCell(withIdentifier: TopicsCell.description(), for: indexPath) as? TopicsCell ?? .init() 242 | cell.tableViewStyle = tableView.style 243 | cell.topic = topics[indexPath.row] 244 | return cell 245 | 246 | case 1: 247 | let cell = tableView.dequeueReusableCell(withIdentifier: UITableViewCell.description(), for: indexPath) 248 | cell.textLabel?.text = "登录后查看更多" 249 | cell.textLabel?.textAlignment = .center 250 | cell.textLabel?.textColor = tableView.tintColor 251 | return cell 252 | 253 | default: 254 | return .init() 255 | } 256 | } 257 | } 258 | 259 | extension TopicsController: UITableViewDelegate { 260 | 261 | func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { 262 | if !topicsIsLoaded && indexPath.row == topics.count - 1 { fetchData() } 263 | } 264 | 265 | func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 266 | switch indexPath.section { 267 | case 0: 268 | let topicController = TopicController() 269 | topicController.topic = topics[indexPath.row] 270 | navigationController?.pushViewController(topicController, animated: true) 271 | 272 | case 1: 273 | tableView.deselectRow(at: indexPath, animated: true) 274 | showSignIn() 275 | 276 | default: 277 | break 278 | } 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /V2EX/Classes/Controllers/TopicController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TopicController.swift 3 | // V2EX 4 | // 5 | // Created by Jianqiu Xiao on 4/8/19. 6 | // Copyright © 2019 Jianqiu Xiao. All rights reserved. 7 | // 8 | 9 | import Alamofire 10 | import Kanna 11 | import TUSafariActivity 12 | 13 | class TopicController: ViewController { 14 | 15 | private var activityIndicatorView: ActivityIndicatorView! 16 | private var bodyCell: TopicBodyCell? 17 | private var commentCells: [TopicCommentCell] = [] 18 | private var isRefreshing = false { didSet { didSetRefreshing() } } 19 | private var networkErrorView: NetworkErrorView! 20 | private var replies: [Reply]? 21 | private var replyCells: [TopicReplyCell] = [] 22 | private var tableView: UITableView! 23 | public var topic: Topic? { didSet { didSetTopic() } } 24 | 25 | override init() { 26 | super.init() 27 | 28 | hidesBottomBarWhenPushed = true 29 | 30 | navigationItem.largeTitleDisplayMode = .never 31 | 32 | toolbarItems = [ 33 | UIBarButtonItem(image: nil, style: .plain, target: self, action: #selector(toggleFavorite)), 34 | UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil), 35 | UIBarButtonItem(barButtonSystemItem: .reply, target: self, action: #selector(showCompose)), 36 | UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil), 37 | UIBarButtonItem(barButtonSystemItem: .trash, target: self, action: #selector(ignore)), 38 | UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil), 39 | UIBarButtonItem(image: UIImage(systemName: "flag"), style: .plain, target: self, action: #selector(showReport)), 40 | UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil), 41 | UIBarButtonItem(barButtonSystemItem: .action, target: self, action: #selector(action)), 42 | ] 43 | } 44 | 45 | override func loadView() { 46 | tableView = UITableView() 47 | tableView.cellLayoutMarginsFollowReadableWidth = true 48 | tableView.dataSource = self 49 | tableView.delegate = self 50 | tableView.refreshControl = UIRefreshControl() 51 | tableView.refreshControl?.addTarget(self, action: #selector(fetchData), for: .valueChanged) 52 | tableView.register(TopicNameCell.self, forCellReuseIdentifier: TopicNameCell.description()) 53 | tableView.tableFooterView = UIView() 54 | view = tableView 55 | 56 | activityIndicatorView = ActivityIndicatorView() 57 | view.addSubview(activityIndicatorView) 58 | 59 | networkErrorView = NetworkErrorView() 60 | networkErrorView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(fetchData))) 61 | view.addSubview(networkErrorView) 62 | } 63 | 64 | override func viewWillAppear(_ animated: Bool) { 65 | super.viewWillAppear(animated) 66 | 67 | navigationController?.isToolbarHidden = false 68 | 69 | if topic?.clicksCount == nil || !networkErrorView.isHidden { fetchData() } 70 | } 71 | 72 | override func viewWillDisappear(_ animated: Bool) { 73 | super.viewWillDisappear(animated) 74 | 75 | navigationController?.isToolbarHidden = true 76 | } 77 | 78 | override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { 79 | super.viewWillTransition(to: size, with: coordinator) 80 | 81 | coordinator.animate(alongsideTransition: nil) { _ in 82 | self.didSetTopic() 83 | self.replyCells = (self.replies ?? []).map { _ in TopicReplyCell() } 84 | self.tableView.reloadData() 85 | } 86 | } 87 | 88 | @objc 89 | private func fetchData() { 90 | if isRefreshing { return } 91 | isRefreshing = true 92 | AF.request( 93 | baseURL 94 | .appendingPathComponent("t") 95 | .appendingPathComponent(String(topic?.id ?? 0)), 96 | parameters: [ 97 | "p": 1, 98 | ] 99 | ) 100 | .responseString { response in 101 | if 200..<300 ~= response.response?.statusCode ?? 0 { 102 | let doc = try? HTML(html: response.value ?? "", encoding: .utf8) 103 | let name = doc?.at_css("h1")?.text 104 | let bodyHTML = doc?.at_css(".topic_content")?.innerHTML 105 | let clicksCount = Int("(\\d+) 次点击".r?.findFirst(in: doc?.at_css(".header .gray")?.text ?? "")?.group(at: 1) ?? "") 106 | let favoritesCount = Int("(\\d+) 人收藏".r?.findFirst(in: doc?.at_css(".topic_stats")?.text ?? "")?.group(at: 1) ?? "") 107 | let repliesCount = Int("^\\d+".r?.findFirst(in: doc?.at_css("#Main .box:nth-child(4) .gray")?.text ?? "")?.matched ?? "") 108 | let createdAt = "[^·]+前|刚刚|\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}".r?.findFirst(in: doc?.at_css(".header .gray")?.text ?? "")?.matched.trimmingCharacters(in: .whitespacesAndNewlines) 109 | let isFavorite = ["加入收藏": false, "取消收藏": true][doc?.at_css(".topic_buttons .tb")?.text] 110 | let favoriteToken = "[^=]+$".r?.findFirst(in: doc?.at_css(".topic_buttons .tb")?["href"] ?? "")?.matched 111 | let once = Int(doc?.at_css("input[name=once]")?["value"] ?? "") 112 | let repliesNextPage = Int(doc?.at_css(".page_current + a")?.text ?? "") 113 | let userJSON = [ 114 | "name": doc?.at_css(".header .gray a")?.text, 115 | "avatar": doc?.at_css(".header .avatar")?["src"], 116 | ] 117 | let nodeJSON = [ 118 | "name": doc?.css(".header a")[2].text, 119 | "code": "[^/]+$".r?.findFirst(in: doc?.css(".header a")[2]["href"] ?? "")?.matched, 120 | ] 121 | let commentsJSON = doc?.css(".subtle").map { 122 | [ 123 | "bodyHTML": $0.at_css(".topic_content")?.innerHTML, 124 | "createdAt": "[^·]+前|刚刚|\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}".r?.findFirst(in: $0.at_css(".fade")?.text ?? "")?.matched.trimmingCharacters(in: .whitespacesAndNewlines), 125 | ] as [String: Any?] 126 | } 127 | let json = [ 128 | "id": self.topic?.id, 129 | "name": name, 130 | "bodyHTML": bodyHTML, 131 | "clicksCount": clicksCount, 132 | "favoritesCount": favoritesCount, 133 | "repliesCount": repliesCount, 134 | "createdAt": createdAt, 135 | "isFavorite": isFavorite, 136 | "favoriteToken": favoriteToken, 137 | "once": once, 138 | "repliesNextPage": repliesNextPage, 139 | "user": userJSON, 140 | "node": nodeJSON, 141 | "comments": commentsJSON, 142 | ] as [String: Any?] 143 | self.topic = try? Topic(json: json) 144 | let repliesJSON = doc?.css("#Main .box:nth-child(4) .cell[id]").map { 145 | [ 146 | "bodyHTML": $0.at_css(".reply_content")?.innerHTML, 147 | "createdAt": ".+前|刚刚|\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}".r?.findFirst(in: $0.at_css(".ago")?.text ?? "")?.matched, 148 | "user": [ 149 | "name": $0.at_css(".dark")?.text, 150 | "avatar": $0.at_css(".avatar")?["src"], 151 | ], 152 | ] as [String: Any?] 153 | } 154 | self.replies = (try? [Reply](json: repliesJSON ?? [])) ?? [] 155 | self.replyCells = (self.replies ?? []).map { _ in TopicReplyCell() } 156 | self.tableView.reloadData() 157 | } else { 158 | self.networkErrorView.isHidden = false 159 | } 160 | self.isRefreshing = false 161 | } 162 | } 163 | 164 | private func fetchReplies() { 165 | guard let page = topic?.repliesNextPage else { return } 166 | if isRefreshing { return } 167 | isRefreshing = true 168 | AF.request( 169 | baseURL 170 | .appendingPathComponent("t") 171 | .appendingPathComponent(String(topic?.id ?? 0)), 172 | parameters: [ 173 | "p": page, 174 | ] 175 | ) 176 | .responseString { response in 177 | if 200..<300 ~= response.response?.statusCode ?? 0 { 178 | let doc = try? HTML(html: response.value ?? "", encoding: .utf8) 179 | self.topic?.repliesNextPage = Int(doc?.at_css(".page_current + a")?.text ?? "") 180 | let json = doc?.css("#Main .box:nth-child(4) .cell[id]").map { 181 | [ 182 | "bodyHTML": $0.at_css(".reply_content")?.innerHTML, 183 | "createdAt": ".+前|刚刚|\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}".r?.findFirst(in: $0.at_css(".ago")?.text ?? "")?.matched, 184 | "user": [ 185 | "name": $0.at_css(".dark")?.text, 186 | "avatar": $0.at_css(".avatar")?["src"], 187 | ], 188 | ] as [String: Any?] 189 | } 190 | let replies = (try? [Reply](json: json ?? [])) ?? [] 191 | self.replies = (self.replies ?? []) + replies 192 | self.replyCells += replies.map { _ in TopicReplyCell() } 193 | self.tableView.reloadSections(IndexSet([3]), with: .none) 194 | } else { 195 | self.networkErrorView.isHidden = false 196 | } 197 | self.isRefreshing = false 198 | } 199 | } 200 | 201 | private func didSetRefreshing() { 202 | if isRefreshing { 203 | networkErrorView.isHidden = true 204 | if tableView.refreshControl?.isRefreshing ?? false { return } 205 | activityIndicatorView.startAnimating() 206 | } else { 207 | tableView.refreshControl?.endRefreshing() 208 | activityIndicatorView.stopAnimating() 209 | } 210 | } 211 | 212 | private func didSetTopic() { 213 | bodyCell = TopicBodyCell() 214 | commentCells = (topic?.comments ?? []).map { _ in TopicCommentCell() } 215 | 216 | toolbarItems?.first?.image = topic?.isFavorite ?? false ? UIImage(systemName: "star.fill") : UIImage(systemName: "star") 217 | 218 | userActivity?.webpageURL = baseURL 219 | .appendingPathComponent("t") 220 | .appendingPathComponent(String(topic?.id ?? 0)) 221 | } 222 | 223 | @objc 224 | func action(_ barButtonItem: UIBarButtonItem) { 225 | let url = baseURL 226 | .appendingPathComponent("t") 227 | .appendingPathComponent(String(topic?.id ?? 0)) 228 | let activityViewController = UIActivityViewController(activityItems: [url], applicationActivities: [TUSafariActivity()]) 229 | activityViewController.popoverPresentationController?.barButtonItem = barButtonItem 230 | present(activityViewController, animated: true) 231 | } 232 | 233 | @objc 234 | private func toggleFavorite(_ sender: Any) { 235 | if ((tabBarController?.viewControllers?.first as? UINavigationController)?.viewControllers.first as? TopicsController)?.user?.once == nil { return signInRequired(sender) } 236 | showHUD() 237 | AF.request( 238 | baseURL 239 | .appendingPathComponent(topic?.isFavorite ?? false ? "unfavorite" : "favorite") 240 | .appendingPathComponent("topic") 241 | .appendingPathComponent(String(topic?.id ?? 0)), 242 | parameters: [ 243 | "t": topic?.favoriteToken ?? "", 244 | ], 245 | headers: [ 246 | "Referer": baseURL.appendingPathComponent("t").appendingPathComponent(String(topic?.id ?? 0)).absoluteString, 247 | ] 248 | ) 249 | .responseString { response in 250 | if 200..<300 ~= response.response?.statusCode ?? 0 { 251 | let doc = try? HTML(html: response.value ?? "", encoding: .utf8) 252 | self.topic?.favoriteToken = "[^=]+$".r?.findFirst(in: doc?.at_css(".topic_buttons .tb")?["href"] ?? "")?.matched 253 | self.topic?.isFavorite?.toggle() 254 | } else { 255 | self.networkError() 256 | } 257 | self.hideHUD() 258 | } 259 | } 260 | 261 | @objc 262 | private func showCompose(_ sender: Any) { 263 | showCompose(sender, nil) 264 | } 265 | 266 | internal func showCompose(_ sender: Any, _ reply: Reply? = nil) { 267 | if ((tabBarController?.viewControllers?.first as? UINavigationController)?.viewControllers.first as? TopicsController)?.user?.once == nil { return signInRequired(sender) } 268 | let composeController = ComposeController() 269 | composeController.reply = reply 270 | composeController.topic = topic 271 | let navigationController = UINavigationController(rootViewController: composeController) 272 | navigationController.modalPresentationStyle = sender is UIBarButtonItem ? .popover : .formSheet 273 | navigationController.popoverPresentationController?.barButtonItem = sender as? UIBarButtonItem 274 | present(navigationController, animated: true) 275 | } 276 | 277 | @objc 278 | func ignore(_ barButtonItem: UIBarButtonItem) { 279 | if ((tabBarController?.viewControllers?.first as? UINavigationController)?.viewControllers.first as? TopicsController)?.user?.once == nil { return signInRequired(barButtonItem) } 280 | let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) 281 | alertController.addAction(UIAlertAction(title: "忽略", style: .destructive) { _ in 282 | self.showHUD() 283 | AF.request( 284 | self.baseURL.appendingPathComponent("/ignore/topic/\(self.topic?.id ?? 0)"), 285 | parameters: [ 286 | "once": self.topic?.once ?? "", 287 | ], 288 | headers: [ 289 | "Referer": self.baseURL.appendingPathComponent("t").appendingPathComponent(String(self.topic?.id ?? 0)).absoluteString, 290 | ] 291 | ) 292 | .response { response in 293 | if 200..<300 ~= response.response?.statusCode ?? 0 { 294 | self.navigationController?.popViewController(animated: true) 295 | } else { 296 | self.networkError() 297 | } 298 | self.hideHUD() 299 | } 300 | }) 301 | alertController.addAction(UIAlertAction(title: "取消", style: .cancel)) 302 | alertController.popoverPresentationController?.barButtonItem = barButtonItem 303 | present(alertController, animated: true) 304 | } 305 | 306 | @objc 307 | private func showReport(_ barButtonItem: UIBarButtonItem) { 308 | let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) 309 | alertController.addAction(UIAlertAction(title: "举报", style: .destructive) { _ in 310 | self.report() 311 | }) 312 | alertController.addAction(UIAlertAction(title: "取消", style: .cancel)) 313 | alertController.popoverPresentationController?.barButtonItem = barButtonItem 314 | present(alertController, animated: true) 315 | } 316 | 317 | internal func report() { 318 | showHUD() 319 | AF.request( 320 | URL(string: "https://ruby-china.net/v2ex") ?? .init(fileURLWithPath: "") 321 | ).response { response in 322 | if 200..<300 ~= response.response?.statusCode ?? 0 { 323 | let alertController = UIAlertController(title: "已举报", message: "24小时之内将会处理", preferredStyle: .alert) 324 | alertController.addAction(UIAlertAction(title: "好", style: .default)) 325 | self.present(alertController, animated: true) 326 | } else { 327 | self.networkError() 328 | } 329 | self.hideHUD() 330 | } 331 | } 332 | } 333 | 334 | extension TopicController: UITableViewDataSource { 335 | 336 | func numberOfSections(in tableView: UITableView) -> Int { 337 | return 4 338 | } 339 | 340 | func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { 341 | if section == 3 && replies != nil { return "\(topic?.repliesCount ?? 0) 回复" } 342 | return nil 343 | } 344 | 345 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 346 | return [ 347 | 1, 348 | topic?.bodyHTML != nil ? 1 : 0, 349 | topic?.comments?.count ?? 0, 350 | replies?.count ?? 0, 351 | ][section] 352 | } 353 | 354 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 355 | switch indexPath.section { 356 | case 0: 357 | let cell = tableView.dequeueReusableCell(withIdentifier: TopicNameCell.description(), for: indexPath) as? TopicNameCell ?? .init() 358 | cell.topic = topic 359 | return cell 360 | 361 | case 1: 362 | let cell = bodyCell ?? .init() 363 | cell.topic = topic 364 | cell.layoutIfNeeded() 365 | return cell 366 | 367 | case 2: 368 | let cell = commentCells[indexPath.row] 369 | cell.comment = topic?.comments?[indexPath.row] 370 | cell.comment?.index = indexPath.row 371 | cell.layoutIfNeeded() 372 | return cell 373 | 374 | case 3: 375 | let cell = replyCells[indexPath.row] 376 | cell.reply = replies?[indexPath.row] 377 | cell.reply?.index = indexPath.row 378 | cell.layoutIfNeeded() 379 | return cell 380 | 381 | default: 382 | return .init() 383 | } 384 | } 385 | } 386 | 387 | extension TopicController: UITableViewDelegate { 388 | 389 | func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { 390 | if indexPath.section == 3 && indexPath.row == (replies?.count ?? 0) - 1 { fetchReplies() } 391 | } 392 | 393 | func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { 394 | switch indexPath.section { 395 | case 3: 396 | return UISwipeActionsConfiguration(actions: [ 397 | UIContextualAction(style: .normal, title: "回复") { _, _, completionHandler in 398 | completionHandler(true) 399 | let cell = tableView.cellForRow(at: indexPath) ?? .init() 400 | self.showCompose(cell, self.replies?[indexPath.row]) 401 | }, 402 | ]) 403 | 404 | default: 405 | return UISwipeActionsConfiguration(actions: []) 406 | } 407 | } 408 | } 409 | -------------------------------------------------------------------------------- /V2EX.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 51; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 95B6016E29031068964A3358 /* Pods_V2EX.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 507B5C8E95694E8A84014586 /* Pods_V2EX.framework */; }; 11 | B227C89C22A6B55400C19F41 /* UIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B227C89B22A6B55400C19F41 /* UIImageView.swift */; }; 12 | B23B1E3F22B7AEED005C7931 /* MoreController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B23B1E3E22B7AEED005C7931 /* MoreController.swift */; }; 13 | B24B36A7225A27210004BCA0 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B24B36A6225A27210004BCA0 /* AppDelegate.swift */; }; 14 | B24B36AE225A27220004BCA0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B24B36AD225A27220004BCA0 /* Assets.xcassets */; }; 15 | B24B36B1225A27220004BCA0 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B24B36AF225A27220004BCA0 /* LaunchScreen.storyboard */; }; 16 | B24B36BB225A2A070004BCA0 /* TopicsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B24B36BA225A2A070004BCA0 /* TopicsController.swift */; }; 17 | B24B36BE225B3DF20004BCA0 /* Topic.swift in Sources */ = {isa = PBXBuildFile; fileRef = B24B36BD225B3DF20004BCA0 /* Topic.swift */; }; 18 | B24B36C0225B60EF0004BCA0 /* Node.swift in Sources */ = {isa = PBXBuildFile; fileRef = B24B36BF225B60EF0004BCA0 /* Node.swift */; }; 19 | B24B36C2225B61190004BCA0 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = B24B36C1225B61190004BCA0 /* User.swift */; }; 20 | B24B36C6225B65A60004BCA0 /* TopicsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B24B36C5225B65A60004BCA0 /* TopicsCell.swift */; }; 21 | B24B36C8225B98810004BCA0 /* TopicController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B24B36C7225B98810004BCA0 /* TopicController.swift */; }; 22 | B24B36CA225B9E070004BCA0 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B24B36C9225B9E070004BCA0 /* ViewController.swift */; }; 23 | B24B36CD225C4D770004BCA0 /* TopicNameCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B24B36CC225C4D770004BCA0 /* TopicNameCell.swift */; }; 24 | B2536D5022667BC6002E2509 /* SignInCaptchaCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2536D4F22667BC6002E2509 /* SignInCaptchaCell.swift */; }; 25 | B2536D522266D377002E2509 /* Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2536D512266D377002E2509 /* Session.swift */; }; 26 | B27F7AEC22A7E040003D52C9 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = B27F7AEB22A7E040003D52C9 /* GoogleService-Info.plist */; }; 27 | B2971DB6226972DF000C6645 /* TopicCommentCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2971DB5226972DF000C6645 /* TopicCommentCell.swift */; }; 28 | B2971DB82269BF75000C6645 /* NodesController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2971DB72269BF75000C6645 /* NodesController.swift */; }; 29 | B2971DBA2269BFB5000C6645 /* Section.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2971DB92269BFB5000C6645 /* Section.swift */; }; 30 | B29F7AAA2265A8FE00A6E7DE /* UserController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B29F7AA92265A8FE00A6E7DE /* UserController.swift */; }; 31 | B29F7AAD22662D3C00A6E7DE /* UserHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B29F7AAC22662D3C00A6E7DE /* UserHeaderView.swift */; }; 32 | B29F7AAF226643CA00A6E7DE /* SignInController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B29F7AAE226643CA00A6E7DE /* SignInController.swift */; }; 33 | B2B2FEE3225E337D00E5364B /* Reply.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B2FEE2225E337D00E5364B /* Reply.swift */; }; 34 | B2B2FEE5225E33F000E5364B /* TopicBodyCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B2FEE4225E33F000E5364B /* TopicBodyCell.swift */; }; 35 | B2B2FEE7225E34DD00E5364B /* TopicWebCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B2FEE6225E34DD00E5364B /* TopicWebCell.swift */; }; 36 | B2B2FEE9225E353F00E5364B /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B2FEE8225E353F00E5364B /* UIView.swift */; }; 37 | B2B2FEEB225E3D8A00E5364B /* TopicReplyCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B2FEEA225E3D8A00E5364B /* TopicReplyCell.swift */; }; 38 | B2B2FEED225F53BC00E5364B /* ActivityIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B2FEEC225F53BC00E5364B /* ActivityIndicatorView.swift */; }; 39 | B2B2FEEF225F53D500E5364B /* NetworkErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B2FEEE225F53D500E5364B /* NetworkErrorView.swift */; }; 40 | B2B2FEF1225F53EB00E5364B /* NoContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B2FEF0225F53EB00E5364B /* NoContentView.swift */; }; 41 | B2B2FEF3225F622500E5364B /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B2FEF2225F622500E5364B /* WebViewController.swift */; }; 42 | B2CA8810226D848A00F8B12F /* TopicsNodeButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2CA880F226D848A00F8B12F /* TopicsNodeButton.swift */; }; 43 | B2CA8812226D97E900F8B12F /* TopicsNodesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2CA8811226D97E900F8B12F /* TopicsNodesView.swift */; }; 44 | B2CA8814226DA79500F8B12F /* SignInUsernameCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2CA8813226DA79500F8B12F /* SignInUsernameCell.swift */; }; 45 | B2CA8816226DA97300F8B12F /* SignInPasswordCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2CA8815226DA97300F8B12F /* SignInPasswordCell.swift */; }; 46 | B2E3704A22AB70980072DDCC /* ComposeController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2E3704922AB70980072DDCC /* ComposeController.swift */; }; 47 | B2E85F8F226717F100D20A3F /* Comment.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2E85F8E226717F100D20A3F /* Comment.swift */; }; 48 | /* End PBXBuildFile section */ 49 | 50 | /* Begin PBXFileReference section */ 51 | 2E0AC9774B17E9B73450E858 /* Pods-V2EX.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-V2EX.debug.xcconfig"; path = "Target Support Files/Pods-V2EX/Pods-V2EX.debug.xcconfig"; sourceTree = ""; }; 52 | 507B5C8E95694E8A84014586 /* Pods_V2EX.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_V2EX.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 53 | 76CB0DEDDE91A97AC1E5014E /* Pods-V2EX.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-V2EX.release.xcconfig"; path = "Target Support Files/Pods-V2EX/Pods-V2EX.release.xcconfig"; sourceTree = ""; }; 54 | B227C89B22A6B55400C19F41 /* UIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImageView.swift; sourceTree = ""; }; 55 | B23B1E3E22B7AEED005C7931 /* MoreController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreController.swift; sourceTree = ""; }; 56 | B24B36A3225A27210004BCA0 /* V2EX.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = V2EX.app; sourceTree = BUILT_PRODUCTS_DIR; }; 57 | B24B36A6225A27210004BCA0 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 58 | B24B36AD225A27220004BCA0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 59 | B24B36B2225A27220004BCA0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 60 | B24B36BA225A2A070004BCA0 /* TopicsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopicsController.swift; sourceTree = ""; }; 61 | B24B36BD225B3DF20004BCA0 /* Topic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Topic.swift; sourceTree = ""; }; 62 | B24B36BF225B60EF0004BCA0 /* Node.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Node.swift; sourceTree = ""; }; 63 | B24B36C1225B61190004BCA0 /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = ""; }; 64 | B24B36C5225B65A60004BCA0 /* TopicsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopicsCell.swift; sourceTree = ""; }; 65 | B24B36C7225B98810004BCA0 /* TopicController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopicController.swift; sourceTree = ""; }; 66 | B24B36C9225B9E070004BCA0 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; 67 | B24B36CC225C4D770004BCA0 /* TopicNameCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopicNameCell.swift; sourceTree = ""; }; 68 | B2536D4F22667BC6002E2509 /* SignInCaptchaCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInCaptchaCell.swift; sourceTree = ""; }; 69 | B2536D512266D377002E2509 /* Session.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Session.swift; sourceTree = ""; }; 70 | B27F7AEB22A7E040003D52C9 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 71 | B2971DB5226972DF000C6645 /* TopicCommentCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopicCommentCell.swift; sourceTree = ""; }; 72 | B2971DB72269BF75000C6645 /* NodesController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodesController.swift; sourceTree = ""; }; 73 | B2971DB92269BFB5000C6645 /* Section.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Section.swift; sourceTree = ""; }; 74 | B29F7AA92265A8FE00A6E7DE /* UserController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserController.swift; sourceTree = ""; }; 75 | B29F7AAC22662D3C00A6E7DE /* UserHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserHeaderView.swift; sourceTree = ""; }; 76 | B29F7AAE226643CA00A6E7DE /* SignInController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInController.swift; sourceTree = ""; }; 77 | B2B2FEE2225E337D00E5364B /* Reply.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Reply.swift; sourceTree = ""; }; 78 | B2B2FEE4225E33F000E5364B /* TopicBodyCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopicBodyCell.swift; sourceTree = ""; }; 79 | B2B2FEE6225E34DD00E5364B /* TopicWebCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopicWebCell.swift; sourceTree = ""; }; 80 | B2B2FEE8225E353F00E5364B /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = ""; }; 81 | B2B2FEEA225E3D8A00E5364B /* TopicReplyCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopicReplyCell.swift; sourceTree = ""; }; 82 | B2B2FEEC225F53BC00E5364B /* ActivityIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityIndicatorView.swift; sourceTree = ""; }; 83 | B2B2FEEE225F53D500E5364B /* NetworkErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkErrorView.swift; sourceTree = ""; }; 84 | B2B2FEF0225F53EB00E5364B /* NoContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoContentView.swift; sourceTree = ""; }; 85 | B2B2FEF2225F622500E5364B /* WebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewController.swift; sourceTree = ""; }; 86 | B2CA880F226D848A00F8B12F /* TopicsNodeButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopicsNodeButton.swift; sourceTree = ""; }; 87 | B2CA8811226D97E900F8B12F /* TopicsNodesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopicsNodesView.swift; sourceTree = ""; }; 88 | B2CA8813226DA79500F8B12F /* SignInUsernameCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInUsernameCell.swift; sourceTree = ""; }; 89 | B2CA8815226DA97300F8B12F /* SignInPasswordCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInPasswordCell.swift; sourceTree = ""; }; 90 | B2E3704922AB70980072DDCC /* ComposeController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeController.swift; sourceTree = ""; }; 91 | B2E6BA7C23364E0000A5F230 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = "zh-Hans"; path = "zh-Hans.lproj/LaunchScreen.storyboard"; sourceTree = ""; }; 92 | B2E85F8E226717F100D20A3F /* Comment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Comment.swift; sourceTree = ""; }; 93 | /* End PBXFileReference section */ 94 | 95 | /* Begin PBXFrameworksBuildPhase section */ 96 | B24B36A0225A27210004BCA0 /* Frameworks */ = { 97 | isa = PBXFrameworksBuildPhase; 98 | buildActionMask = 2147483647; 99 | files = ( 100 | 95B6016E29031068964A3358 /* Pods_V2EX.framework in Frameworks */, 101 | ); 102 | runOnlyForDeploymentPostprocessing = 0; 103 | }; 104 | /* End PBXFrameworksBuildPhase section */ 105 | 106 | /* Begin PBXGroup section */ 107 | 5CBD6359BFF72C505743A6B5 /* Pods */ = { 108 | isa = PBXGroup; 109 | children = ( 110 | 2E0AC9774B17E9B73450E858 /* Pods-V2EX.debug.xcconfig */, 111 | 76CB0DEDDE91A97AC1E5014E /* Pods-V2EX.release.xcconfig */, 112 | ); 113 | path = Pods; 114 | sourceTree = ""; 115 | }; 116 | B24B369A225A27210004BCA0 = { 117 | isa = PBXGroup; 118 | children = ( 119 | B24B36A5225A27210004BCA0 /* V2EX */, 120 | B24B36A4225A27210004BCA0 /* Products */, 121 | 5CBD6359BFF72C505743A6B5 /* Pods */, 122 | C7368866D6E4DA588E1B7498 /* Frameworks */, 123 | ); 124 | sourceTree = ""; 125 | }; 126 | B24B36A4225A27210004BCA0 /* Products */ = { 127 | isa = PBXGroup; 128 | children = ( 129 | B24B36A3225A27210004BCA0 /* V2EX.app */, 130 | ); 131 | name = Products; 132 | sourceTree = ""; 133 | }; 134 | B24B36A5225A27210004BCA0 /* V2EX */ = { 135 | isa = PBXGroup; 136 | children = ( 137 | B24B36A6225A27210004BCA0 /* AppDelegate.swift */, 138 | B24B36AD225A27220004BCA0 /* Assets.xcassets */, 139 | B24B36B8225A29EA0004BCA0 /* Classes */, 140 | B27F7AEB22A7E040003D52C9 /* GoogleService-Info.plist */, 141 | B24B36B2225A27220004BCA0 /* Info.plist */, 142 | B24B36AF225A27220004BCA0 /* LaunchScreen.storyboard */, 143 | ); 144 | path = V2EX; 145 | sourceTree = ""; 146 | }; 147 | B24B36B8225A29EA0004BCA0 /* Classes */ = { 148 | isa = PBXGroup; 149 | children = ( 150 | B24B36B9225A29F10004BCA0 /* Controllers */, 151 | B24B36BC225B3DDF0004BCA0 /* Models */, 152 | B24B36C3225B65900004BCA0 /* Views */, 153 | ); 154 | path = Classes; 155 | sourceTree = ""; 156 | }; 157 | B24B36B9225A29F10004BCA0 /* Controllers */ = { 158 | isa = PBXGroup; 159 | children = ( 160 | B2E3704922AB70980072DDCC /* ComposeController.swift */, 161 | B23B1E3E22B7AEED005C7931 /* MoreController.swift */, 162 | B2971DB72269BF75000C6645 /* NodesController.swift */, 163 | B29F7AAE226643CA00A6E7DE /* SignInController.swift */, 164 | B24B36C7225B98810004BCA0 /* TopicController.swift */, 165 | B24B36BA225A2A070004BCA0 /* TopicsController.swift */, 166 | B29F7AA92265A8FE00A6E7DE /* UserController.swift */, 167 | B24B36C9225B9E070004BCA0 /* ViewController.swift */, 168 | B2B2FEF2225F622500E5364B /* WebViewController.swift */, 169 | ); 170 | path = Controllers; 171 | sourceTree = ""; 172 | }; 173 | B24B36BC225B3DDF0004BCA0 /* Models */ = { 174 | isa = PBXGroup; 175 | children = ( 176 | B2E85F8E226717F100D20A3F /* Comment.swift */, 177 | B24B36BF225B60EF0004BCA0 /* Node.swift */, 178 | B2B2FEE2225E337D00E5364B /* Reply.swift */, 179 | B2971DB92269BFB5000C6645 /* Section.swift */, 180 | B2536D512266D377002E2509 /* Session.swift */, 181 | B24B36BD225B3DF20004BCA0 /* Topic.swift */, 182 | B24B36C1225B61190004BCA0 /* User.swift */, 183 | ); 184 | path = Models; 185 | sourceTree = ""; 186 | }; 187 | B24B36C3225B65900004BCA0 /* Views */ = { 188 | isa = PBXGroup; 189 | children = ( 190 | B2B2FEEC225F53BC00E5364B /* ActivityIndicatorView.swift */, 191 | B2B2FEEE225F53D500E5364B /* NetworkErrorView.swift */, 192 | B2B2FEF0225F53EB00E5364B /* NoContentView.swift */, 193 | B2536D4E22667BAD002E2509 /* SignIn */, 194 | B24B36CB225C4D680004BCA0 /* Topic */, 195 | B24B36C4225B659C0004BCA0 /* Topics */, 196 | B227C89B22A6B55400C19F41 /* UIImageView.swift */, 197 | B2B2FEE8225E353F00E5364B /* UIView.swift */, 198 | B29F7AAB22662D2900A6E7DE /* User */, 199 | ); 200 | path = Views; 201 | sourceTree = ""; 202 | }; 203 | B24B36C4225B659C0004BCA0 /* Topics */ = { 204 | isa = PBXGroup; 205 | children = ( 206 | B24B36C5225B65A60004BCA0 /* TopicsCell.swift */, 207 | B2CA880F226D848A00F8B12F /* TopicsNodeButton.swift */, 208 | B2CA8811226D97E900F8B12F /* TopicsNodesView.swift */, 209 | ); 210 | path = Topics; 211 | sourceTree = ""; 212 | }; 213 | B24B36CB225C4D680004BCA0 /* Topic */ = { 214 | isa = PBXGroup; 215 | children = ( 216 | B2B2FEE4225E33F000E5364B /* TopicBodyCell.swift */, 217 | B2971DB5226972DF000C6645 /* TopicCommentCell.swift */, 218 | B24B36CC225C4D770004BCA0 /* TopicNameCell.swift */, 219 | B2B2FEEA225E3D8A00E5364B /* TopicReplyCell.swift */, 220 | B2B2FEE6225E34DD00E5364B /* TopicWebCell.swift */, 221 | ); 222 | path = Topic; 223 | sourceTree = ""; 224 | }; 225 | B2536D4E22667BAD002E2509 /* SignIn */ = { 226 | isa = PBXGroup; 227 | children = ( 228 | B2536D4F22667BC6002E2509 /* SignInCaptchaCell.swift */, 229 | B2CA8815226DA97300F8B12F /* SignInPasswordCell.swift */, 230 | B2CA8813226DA79500F8B12F /* SignInUsernameCell.swift */, 231 | ); 232 | path = SignIn; 233 | sourceTree = ""; 234 | }; 235 | B29F7AAB22662D2900A6E7DE /* User */ = { 236 | isa = PBXGroup; 237 | children = ( 238 | B29F7AAC22662D3C00A6E7DE /* UserHeaderView.swift */, 239 | ); 240 | path = User; 241 | sourceTree = ""; 242 | }; 243 | C7368866D6E4DA588E1B7498 /* Frameworks */ = { 244 | isa = PBXGroup; 245 | children = ( 246 | 507B5C8E95694E8A84014586 /* Pods_V2EX.framework */, 247 | ); 248 | name = Frameworks; 249 | sourceTree = ""; 250 | }; 251 | /* End PBXGroup section */ 252 | 253 | /* Begin PBXNativeTarget section */ 254 | B24B36A2225A27210004BCA0 /* V2EX */ = { 255 | isa = PBXNativeTarget; 256 | buildConfigurationList = B24B36B5225A27220004BCA0 /* Build configuration list for PBXNativeTarget "V2EX" */; 257 | buildPhases = ( 258 | C65A1F9486CA18818E7B513C /* [CP] Check Pods Manifest.lock */, 259 | B24B369F225A27210004BCA0 /* Sources */, 260 | B24B36A0225A27210004BCA0 /* Frameworks */, 261 | B24B36A1225A27210004BCA0 /* Resources */, 262 | 4CE3C8611EF3860AA8F66B11 /* [CP] Embed Pods Frameworks */, 263 | B2AF991D226A10D400454882 /* ShellScript */, 264 | ); 265 | buildRules = ( 266 | ); 267 | dependencies = ( 268 | ); 269 | name = V2EX; 270 | productName = V2EX; 271 | productReference = B24B36A3225A27210004BCA0 /* V2EX.app */; 272 | productType = "com.apple.product-type.application"; 273 | }; 274 | /* End PBXNativeTarget section */ 275 | 276 | /* Begin PBXProject section */ 277 | B24B369B225A27210004BCA0 /* Project object */ = { 278 | isa = PBXProject; 279 | attributes = { 280 | LastSwiftUpdateCheck = 1020; 281 | LastUpgradeCheck = 1020; 282 | ORGANIZATIONNAME = "Jianqiu Xiao"; 283 | TargetAttributes = { 284 | B24B36A2225A27210004BCA0 = { 285 | CreatedOnToolsVersion = 10.2; 286 | }; 287 | }; 288 | }; 289 | buildConfigurationList = B24B369E225A27210004BCA0 /* Build configuration list for PBXProject "V2EX" */; 290 | compatibilityVersion = "Xcode 9.3"; 291 | developmentRegion = "zh-Hans"; 292 | hasScannedForEncodings = 0; 293 | knownRegions = ( 294 | "zh-Hans", 295 | ); 296 | mainGroup = B24B369A225A27210004BCA0; 297 | productRefGroup = B24B36A4225A27210004BCA0 /* Products */; 298 | projectDirPath = ""; 299 | projectRoot = ""; 300 | targets = ( 301 | B24B36A2225A27210004BCA0 /* V2EX */, 302 | ); 303 | }; 304 | /* End PBXProject section */ 305 | 306 | /* Begin PBXResourcesBuildPhase section */ 307 | B24B36A1225A27210004BCA0 /* Resources */ = { 308 | isa = PBXResourcesBuildPhase; 309 | buildActionMask = 2147483647; 310 | files = ( 311 | B24B36B1225A27220004BCA0 /* LaunchScreen.storyboard in Resources */, 312 | B24B36AE225A27220004BCA0 /* Assets.xcassets in Resources */, 313 | B27F7AEC22A7E040003D52C9 /* GoogleService-Info.plist in Resources */, 314 | ); 315 | runOnlyForDeploymentPostprocessing = 0; 316 | }; 317 | /* End PBXResourcesBuildPhase section */ 318 | 319 | /* Begin PBXShellScriptBuildPhase section */ 320 | 4CE3C8611EF3860AA8F66B11 /* [CP] Embed Pods Frameworks */ = { 321 | isa = PBXShellScriptBuildPhase; 322 | buildActionMask = 2147483647; 323 | files = ( 324 | ); 325 | inputFileListPaths = ( 326 | "${PODS_ROOT}/Target Support Files/Pods-V2EX/Pods-V2EX-frameworks-${CONFIGURATION}-input-files.xcfilelist", 327 | ); 328 | name = "[CP] Embed Pods Frameworks"; 329 | outputFileListPaths = ( 330 | "${PODS_ROOT}/Target Support Files/Pods-V2EX/Pods-V2EX-frameworks-${CONFIGURATION}-output-files.xcfilelist", 331 | ); 332 | runOnlyForDeploymentPostprocessing = 0; 333 | shellPath = /bin/sh; 334 | shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-V2EX/Pods-V2EX-frameworks.sh\"\n"; 335 | showEnvVarsInLog = 0; 336 | }; 337 | B2AF991D226A10D400454882 /* ShellScript */ = { 338 | isa = PBXShellScriptBuildPhase; 339 | buildActionMask = 2147483647; 340 | files = ( 341 | ); 342 | inputFileListPaths = ( 343 | ); 344 | inputPaths = ( 345 | ); 346 | outputFileListPaths = ( 347 | ); 348 | outputPaths = ( 349 | ); 350 | runOnlyForDeploymentPostprocessing = 0; 351 | shellPath = /bin/sh; 352 | shellScript = "\"${PODS_ROOT}/SwiftLint/swiftlint\"\n"; 353 | }; 354 | C65A1F9486CA18818E7B513C /* [CP] Check Pods Manifest.lock */ = { 355 | isa = PBXShellScriptBuildPhase; 356 | buildActionMask = 2147483647; 357 | files = ( 358 | ); 359 | inputFileListPaths = ( 360 | ); 361 | inputPaths = ( 362 | "${PODS_PODFILE_DIR_PATH}/Podfile.lock", 363 | "${PODS_ROOT}/Manifest.lock", 364 | ); 365 | name = "[CP] Check Pods Manifest.lock"; 366 | outputFileListPaths = ( 367 | ); 368 | outputPaths = ( 369 | "$(DERIVED_FILE_DIR)/Pods-V2EX-checkManifestLockResult.txt", 370 | ); 371 | runOnlyForDeploymentPostprocessing = 0; 372 | shellPath = /bin/sh; 373 | shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; 374 | showEnvVarsInLog = 0; 375 | }; 376 | /* End PBXShellScriptBuildPhase section */ 377 | 378 | /* Begin PBXSourcesBuildPhase section */ 379 | B24B369F225A27210004BCA0 /* Sources */ = { 380 | isa = PBXSourcesBuildPhase; 381 | buildActionMask = 2147483647; 382 | files = ( 383 | B2B2FEEF225F53D500E5364B /* NetworkErrorView.swift in Sources */, 384 | B2B2FEF3225F622500E5364B /* WebViewController.swift in Sources */, 385 | B29F7AAA2265A8FE00A6E7DE /* UserController.swift in Sources */, 386 | B2B2FEE9225E353F00E5364B /* UIView.swift in Sources */, 387 | B24B36CD225C4D770004BCA0 /* TopicNameCell.swift in Sources */, 388 | B23B1E3F22B7AEED005C7931 /* MoreController.swift in Sources */, 389 | B2B2FEEB225E3D8A00E5364B /* TopicReplyCell.swift in Sources */, 390 | B24B36A7225A27210004BCA0 /* AppDelegate.swift in Sources */, 391 | B29F7AAD22662D3C00A6E7DE /* UserHeaderView.swift in Sources */, 392 | B2971DB82269BF75000C6645 /* NodesController.swift in Sources */, 393 | B2971DBA2269BFB5000C6645 /* Section.swift in Sources */, 394 | B2B2FEF1225F53EB00E5364B /* NoContentView.swift in Sources */, 395 | B24B36BE225B3DF20004BCA0 /* Topic.swift in Sources */, 396 | B2E85F8F226717F100D20A3F /* Comment.swift in Sources */, 397 | B24B36BB225A2A070004BCA0 /* TopicsController.swift in Sources */, 398 | B2CA8816226DA97300F8B12F /* SignInPasswordCell.swift in Sources */, 399 | B2B2FEED225F53BC00E5364B /* ActivityIndicatorView.swift in Sources */, 400 | B2B2FEE5225E33F000E5364B /* TopicBodyCell.swift in Sources */, 401 | B2CA8812226D97E900F8B12F /* TopicsNodesView.swift in Sources */, 402 | B24B36C2225B61190004BCA0 /* User.swift in Sources */, 403 | B2536D5022667BC6002E2509 /* SignInCaptchaCell.swift in Sources */, 404 | B24B36C0225B60EF0004BCA0 /* Node.swift in Sources */, 405 | B2CA8814226DA79500F8B12F /* SignInUsernameCell.swift in Sources */, 406 | B2CA8810226D848A00F8B12F /* TopicsNodeButton.swift in Sources */, 407 | B2B2FEE3225E337D00E5364B /* Reply.swift in Sources */, 408 | B24B36C8225B98810004BCA0 /* TopicController.swift in Sources */, 409 | B24B36CA225B9E070004BCA0 /* ViewController.swift in Sources */, 410 | B2971DB6226972DF000C6645 /* TopicCommentCell.swift in Sources */, 411 | B227C89C22A6B55400C19F41 /* UIImageView.swift in Sources */, 412 | B2536D522266D377002E2509 /* Session.swift in Sources */, 413 | B2E3704A22AB70980072DDCC /* ComposeController.swift in Sources */, 414 | B2B2FEE7225E34DD00E5364B /* TopicWebCell.swift in Sources */, 415 | B24B36C6225B65A60004BCA0 /* TopicsCell.swift in Sources */, 416 | B29F7AAF226643CA00A6E7DE /* SignInController.swift in Sources */, 417 | ); 418 | runOnlyForDeploymentPostprocessing = 0; 419 | }; 420 | /* End PBXSourcesBuildPhase section */ 421 | 422 | /* Begin PBXVariantGroup section */ 423 | B24B36AF225A27220004BCA0 /* LaunchScreen.storyboard */ = { 424 | isa = PBXVariantGroup; 425 | children = ( 426 | B2E6BA7C23364E0000A5F230 /* zh-Hans */, 427 | ); 428 | name = LaunchScreen.storyboard; 429 | sourceTree = ""; 430 | }; 431 | /* End PBXVariantGroup section */ 432 | 433 | /* Begin XCBuildConfiguration section */ 434 | B24B36B3225A27220004BCA0 /* Debug */ = { 435 | isa = XCBuildConfiguration; 436 | buildSettings = { 437 | ALWAYS_SEARCH_USER_PATHS = NO; 438 | CLANG_ANALYZER_NONNULL = YES; 439 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 440 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 441 | CLANG_CXX_LIBRARY = "libc++"; 442 | CLANG_ENABLE_MODULES = YES; 443 | CLANG_ENABLE_OBJC_ARC = YES; 444 | CLANG_ENABLE_OBJC_WEAK = YES; 445 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 446 | CLANG_WARN_BOOL_CONVERSION = YES; 447 | CLANG_WARN_COMMA = YES; 448 | CLANG_WARN_CONSTANT_CONVERSION = YES; 449 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 450 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 451 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 452 | CLANG_WARN_EMPTY_BODY = YES; 453 | CLANG_WARN_ENUM_CONVERSION = YES; 454 | CLANG_WARN_INFINITE_RECURSION = YES; 455 | CLANG_WARN_INT_CONVERSION = YES; 456 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 457 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 458 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 459 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 460 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 461 | CLANG_WARN_STRICT_PROTOTYPES = YES; 462 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 463 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 464 | CLANG_WARN_UNREACHABLE_CODE = YES; 465 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 466 | CODE_SIGN_IDENTITY = "iPhone Developer"; 467 | COPY_PHASE_STRIP = NO; 468 | DEBUG_INFORMATION_FORMAT = dwarf; 469 | ENABLE_STRICT_OBJC_MSGSEND = YES; 470 | ENABLE_TESTABILITY = YES; 471 | GCC_C_LANGUAGE_STANDARD = gnu11; 472 | GCC_DYNAMIC_NO_PIC = NO; 473 | GCC_NO_COMMON_BLOCKS = YES; 474 | GCC_OPTIMIZATION_LEVEL = 0; 475 | GCC_PREPROCESSOR_DEFINITIONS = ( 476 | "DEBUG=1", 477 | "$(inherited)", 478 | ); 479 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 480 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 481 | GCC_WARN_UNDECLARED_SELECTOR = YES; 482 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 483 | GCC_WARN_UNUSED_FUNCTION = YES; 484 | GCC_WARN_UNUSED_VARIABLE = YES; 485 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 486 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 487 | MTL_FAST_MATH = YES; 488 | ONLY_ACTIVE_ARCH = YES; 489 | SDKROOT = iphoneos; 490 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 491 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 492 | }; 493 | name = Debug; 494 | }; 495 | B24B36B4225A27220004BCA0 /* Release */ = { 496 | isa = XCBuildConfiguration; 497 | buildSettings = { 498 | ALWAYS_SEARCH_USER_PATHS = NO; 499 | CLANG_ANALYZER_NONNULL = YES; 500 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 501 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 502 | CLANG_CXX_LIBRARY = "libc++"; 503 | CLANG_ENABLE_MODULES = YES; 504 | CLANG_ENABLE_OBJC_ARC = YES; 505 | CLANG_ENABLE_OBJC_WEAK = YES; 506 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 507 | CLANG_WARN_BOOL_CONVERSION = YES; 508 | CLANG_WARN_COMMA = YES; 509 | CLANG_WARN_CONSTANT_CONVERSION = YES; 510 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 511 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 512 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 513 | CLANG_WARN_EMPTY_BODY = YES; 514 | CLANG_WARN_ENUM_CONVERSION = YES; 515 | CLANG_WARN_INFINITE_RECURSION = YES; 516 | CLANG_WARN_INT_CONVERSION = YES; 517 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 518 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 519 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 520 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 521 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 522 | CLANG_WARN_STRICT_PROTOTYPES = YES; 523 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 524 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 525 | CLANG_WARN_UNREACHABLE_CODE = YES; 526 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 527 | CODE_SIGN_IDENTITY = "iPhone Developer"; 528 | COPY_PHASE_STRIP = NO; 529 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 530 | ENABLE_NS_ASSERTIONS = NO; 531 | ENABLE_STRICT_OBJC_MSGSEND = YES; 532 | GCC_C_LANGUAGE_STANDARD = gnu11; 533 | GCC_NO_COMMON_BLOCKS = YES; 534 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 535 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 536 | GCC_WARN_UNDECLARED_SELECTOR = YES; 537 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 538 | GCC_WARN_UNUSED_FUNCTION = YES; 539 | GCC_WARN_UNUSED_VARIABLE = YES; 540 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 541 | MTL_ENABLE_DEBUG_INFO = NO; 542 | MTL_FAST_MATH = YES; 543 | SDKROOT = iphoneos; 544 | SWIFT_COMPILATION_MODE = wholemodule; 545 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 546 | VALIDATE_PRODUCT = YES; 547 | }; 548 | name = Release; 549 | }; 550 | B24B36B6225A27220004BCA0 /* Debug */ = { 551 | isa = XCBuildConfiguration; 552 | baseConfigurationReference = 2E0AC9774B17E9B73450E858 /* Pods-V2EX.debug.xcconfig */; 553 | buildSettings = { 554 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 555 | CODE_SIGN_STYLE = Automatic; 556 | CURRENT_PROJECT_VERSION = 12; 557 | DEVELOPMENT_TEAM = 4ZT4M533K7; 558 | INFOPLIST_FILE = V2EX/Info.plist; 559 | LD_RUNPATH_SEARCH_PATHS = ( 560 | "$(inherited)", 561 | "@executable_path/Frameworks", 562 | ); 563 | MARKETING_VERSION = 1.4; 564 | PRODUCT_BUNDLE_IDENTIFIER = com.secipin.v2ex; 565 | PRODUCT_NAME = "$(TARGET_NAME)"; 566 | SWIFT_VERSION = 5.0; 567 | TARGETED_DEVICE_FAMILY = "1,2"; 568 | }; 569 | name = Debug; 570 | }; 571 | B24B36B7225A27220004BCA0 /* Release */ = { 572 | isa = XCBuildConfiguration; 573 | baseConfigurationReference = 76CB0DEDDE91A97AC1E5014E /* Pods-V2EX.release.xcconfig */; 574 | buildSettings = { 575 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 576 | CODE_SIGN_STYLE = Automatic; 577 | CURRENT_PROJECT_VERSION = 12; 578 | DEVELOPMENT_TEAM = 4ZT4M533K7; 579 | INFOPLIST_FILE = V2EX/Info.plist; 580 | LD_RUNPATH_SEARCH_PATHS = ( 581 | "$(inherited)", 582 | "@executable_path/Frameworks", 583 | ); 584 | MARKETING_VERSION = 1.4; 585 | PRODUCT_BUNDLE_IDENTIFIER = com.secipin.v2ex; 586 | PRODUCT_NAME = "$(TARGET_NAME)"; 587 | SWIFT_VERSION = 5.0; 588 | TARGETED_DEVICE_FAMILY = "1,2"; 589 | }; 590 | name = Release; 591 | }; 592 | /* End XCBuildConfiguration section */ 593 | 594 | /* Begin XCConfigurationList section */ 595 | B24B369E225A27210004BCA0 /* Build configuration list for PBXProject "V2EX" */ = { 596 | isa = XCConfigurationList; 597 | buildConfigurations = ( 598 | B24B36B3225A27220004BCA0 /* Debug */, 599 | B24B36B4225A27220004BCA0 /* Release */, 600 | ); 601 | defaultConfigurationIsVisible = 0; 602 | defaultConfigurationName = Release; 603 | }; 604 | B24B36B5225A27220004BCA0 /* Build configuration list for PBXNativeTarget "V2EX" */ = { 605 | isa = XCConfigurationList; 606 | buildConfigurations = ( 607 | B24B36B6225A27220004BCA0 /* Debug */, 608 | B24B36B7225A27220004BCA0 /* Release */, 609 | ); 610 | defaultConfigurationIsVisible = 0; 611 | defaultConfigurationName = Release; 612 | }; 613 | /* End XCConfigurationList section */ 614 | }; 615 | rootObject = B24B369B225A27210004BCA0 /* Project object */; 616 | } 617 | --------------------------------------------------------------------------------