Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Ruizhi] OpenTweet #5

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
62 changes: 61 additions & 1 deletion OpenTweet.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@
009C4C811D9F0CD600F0BC6C /* OpenTweetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 009C4C801D9F0CD600F0BC6C /* OpenTweetTests.swift */; };
009C4C8C1D9F0CD600F0BC6C /* OpenTweetUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 009C4C8B1D9F0CD600F0BC6C /* OpenTweetUITests.swift */; };
009C4C9B1D9F0D4100F0BC6C /* timeline.json in Resources */ = {isa = PBXBuildFile; fileRef = 009C4C9A1D9F0D4100F0BC6C /* timeline.json */; };
EA2E8E302B543F5400620D98 /* Tweet.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2E8E2F2B543F5400620D98 /* Tweet.swift */; };
EA2E8E322B5440EC00620D98 /* TimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2E8E312B5440EC00620D98 /* TimelineViewModel.swift */; };
EA2E8E352B54442600620D98 /* TimelineTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2E8E342B54442600620D98 /* TimelineTableViewCell.swift */; };
EA2E8E382B547B9800620D98 /* FormatDateUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2E8E372B547B9800620D98 /* FormatDateUtils.swift */; };
EA2E8E3A2B54834500620D98 /* Networks.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2E8E392B54834500620D98 /* Networks.swift */; };
EA898B942B55C9AF00306DAB /* TweetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA898B932B55C9AF00306DAB /* TweetViewController.swift */; };
EA898B962B55D19C00306DAB /* TweetViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA898B952B55D19C00306DAB /* TweetViewModel.swift */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand Down Expand Up @@ -50,6 +57,13 @@
009C4C8D1D9F0CD600F0BC6C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
009C4C9A1D9F0D4100F0BC6C /* timeline.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = timeline.json; sourceTree = "<group>"; };
009C4C9D1D9F104800F0BC6C /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
EA2E8E2F2B543F5400620D98 /* Tweet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tweet.swift; sourceTree = "<group>"; };
EA2E8E312B5440EC00620D98 /* TimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewModel.swift; sourceTree = "<group>"; };
EA2E8E342B54442600620D98 /* TimelineTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTableViewCell.swift; sourceTree = "<group>"; };
EA2E8E372B547B9800620D98 /* FormatDateUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormatDateUtils.swift; sourceTree = "<group>"; };
EA2E8E392B54834500620D98 /* Networks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Networks.swift; sourceTree = "<group>"; };
EA898B932B55C9AF00306DAB /* TweetViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TweetViewController.swift; sourceTree = "<group>"; };
EA898B952B55D19C00306DAB /* TweetViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TweetViewModel.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -102,8 +116,11 @@
009C4C6A1D9F0CD600F0BC6C /* OpenTweet */ = {
isa = PBXGroup;
children = (
EA2E8E362B547B6800620D98 /* Utils */,
EA2E8E332B54440A00620D98 /* Cell */,
EA2E8E2E2B543F2300620D98 /* Models */,
009C4C6B1D9F0CD600F0BC6C /* AppDelegate.swift */,
009C4C6D1D9F0CD600F0BC6C /* TimelineViewController.swift */,
EA898B972B55D5A200306DAB /* ViewControllers */,
009C4C6F1D9F0CD600F0BC6C /* Main.storyboard */,
009C4C741D9F0CD600F0BC6C /* LaunchScreen.storyboard */,
009C4C721D9F0CD600F0BC6C /* Assets.xcassets */,
Expand Down Expand Up @@ -138,6 +155,42 @@
path = Data;
sourceTree = "<group>";
};
EA2E8E2E2B543F2300620D98 /* Models */ = {
isa = PBXGroup;
children = (
EA2E8E2F2B543F5400620D98 /* Tweet.swift */,
EA2E8E312B5440EC00620D98 /* TimelineViewModel.swift */,
EA898B952B55D19C00306DAB /* TweetViewModel.swift */,
);
path = Models;
sourceTree = "<group>";
};
EA2E8E332B54440A00620D98 /* Cell */ = {
isa = PBXGroup;
children = (
EA2E8E342B54442600620D98 /* TimelineTableViewCell.swift */,
);
path = Cell;
sourceTree = "<group>";
};
EA2E8E362B547B6800620D98 /* Utils */ = {
isa = PBXGroup;
children = (
EA2E8E372B547B9800620D98 /* FormatDateUtils.swift */,
EA2E8E392B54834500620D98 /* Networks.swift */,
);
path = Utils;
sourceTree = "<group>";
};
EA898B972B55D5A200306DAB /* ViewControllers */ = {
isa = PBXGroup;
children = (
EA898B932B55C9AF00306DAB /* TweetViewController.swift */,
009C4C6D1D9F0CD600F0BC6C /* TimelineViewController.swift */,
);
path = ViewControllers;
sourceTree = "<group>";
};
/* End PBXGroup section */

/* Begin PBXNativeTarget section */
Expand Down Expand Up @@ -276,6 +329,13 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
EA2E8E382B547B9800620D98 /* FormatDateUtils.swift in Sources */,
EA898B942B55C9AF00306DAB /* TweetViewController.swift in Sources */,
EA2E8E302B543F5400620D98 /* Tweet.swift in Sources */,
EA2E8E322B5440EC00620D98 /* TimelineViewModel.swift in Sources */,
EA2E8E352B54442600620D98 /* TimelineTableViewCell.swift in Sources */,
EA2E8E3A2B54834500620D98 /* Networks.swift in Sources */,
EA898B962B55D19C00306DAB /* TweetViewModel.swift in Sources */,
009C4C6E1D9F0CD600F0BC6C /* TimelineViewController.swift in Sources */,
009C4C6C1D9F0CD600F0BC6C /* AppDelegate.swift in Sources */,
);
Expand Down
6 changes: 6 additions & 0 deletions OpenTweet/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate {

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
window = UIWindow(frame: UIScreen.main.bounds)
let vc = TimelineViewController()
let navigation = UINavigationController(rootViewController: vc)
window?.rootViewController = navigation
window?.makeKeyAndVisible()

return true
}

Expand Down
155 changes: 155 additions & 0 deletions OpenTweet/Cell/TimelineTableViewCell.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
//
// TimelineTableViewCell.swift
// OpenTweet
//
// Created by Dante Li on 2024-01-14.
// Copyright © 2024 OpenTable, Inc. All rights reserved.
//

import UIKit

final class TimelineTableViewCell: UITableViewCell {

var tweet: Tweet? {
didSet {
nameLabel.text = tweet?.author
timeLabel.text = tweet?.formattedDateString
contentLabel.highlightLink(in: tweet?.content)
if let avatarData = tweet?.avatarData {
avatarImageView.image = UIImage(data: avatarData)
}
}
}

override func awakeFromNib() {
super.awakeFromNib()
// Initialization code
}

override func setSelected(_ selected: Bool, animated: Bool) {
super.setSelected(false, animated: animated)

// Configure the view for the selected state
}

// MARK: - Init

override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)

setupUI()
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

// MARK: - Subviews and setup

private let avatarWidth: Double = 30.0

private lazy var nameLabel: UILabel = {
let label = UILabel()
label.font = UIFont.systemFont(ofSize: 15.0, weight: .bold)
return label
}()

private lazy var timeLabel: UILabel = {
let label = UILabel()
label.font = UIFont.systemFont(ofSize: 12.0, weight: .light)
return label
}()

private lazy var avatarImageView: UIImageView = {
let imageView = UIImageView()
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.widthAnchor.constraint(equalToConstant: avatarWidth).isActive = true
imageView.heightAnchor.constraint(equalToConstant: avatarWidth).isActive = true
imageView.layer.cornerRadius = avatarWidth / 2
imageView.clipsToBounds = true
imageView.backgroundColor = UIColor.gray
return imageView
}()

private lazy var contentLabel: UILabel = {
let label = UILabel()
label.font = UIFont.systemFont(ofSize: 15.0, weight: .light)
label.numberOfLines = 0
return label
}()

private lazy var stackView: UIStackView = {
let stack = UIStackView(arrangedSubviews: [nameLabel, timeLabel, contentLabel])
stack.axis = .vertical
stack.alignment = .fill
stack.spacing = 5.0
stack.translatesAutoresizingMaskIntoConstraints = false
return stack
}()

private func setupUI() {
contentView.addSubview(avatarImageView)
contentView.addSubview(stackView)
NSLayoutConstraint.activate([
avatarImageView.topAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.topAnchor, constant: 10.0),
avatarImageView.leadingAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.leadingAnchor, constant: 10.0),
stackView.topAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.topAnchor, constant: 10.0),
stackView.leadingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: 10),
stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -10),
stackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10)
])
}
}

extension UILabel {

func highlightLink(in text: String?) {
guard let text = text else { return }

// Detects links in the text
let detector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
let matches = detector.matches(
in: text,
options: [],
range: NSRange(location: 0, length: text.utf16.count)
)

if matches.isEmpty {
self.text = text
return
}

// Sets up attributed string with links
let attributedString = NSMutableAttributedString(string: text)
for match in matches {
guard let url = match.url else { continue }
let range = match.range
attributedString.addAttribute(
.link,
value: url,
range: range
)

attributedString.addAttribute(
.foregroundColor,
value: UIColor.blue,
range: range
)

attributedString.addAttribute(
.underlineStyle,
value: NSUnderlineStyle.single.rawValue,
range: match.range
)
}

self.attributedText = attributedString
}
}

extension Tweet {

var formattedDateString: String? {
FormatDateUtils.format(rawDateString: dateString)
}
}
2 changes: 0 additions & 2 deletions OpenTweet/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,6 @@
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
Expand Down
81 changes: 81 additions & 0 deletions OpenTweet/Models/TimelineViewModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
//
// TimelineViewModel.swift
// OpenTweet
//
// Created by Dante Li on 2024-01-14.
// Copyright © 2024 OpenTable, Inc. All rights reserved.
//

import Combine
import Foundation

protocol TimelineViewModel {
var tweetsPublisher: AnyPublisher<[Tweet], Never> { get }
func fetchData()
func retrieveAvatar(in tweet: Tweet) async -> Data?
}

final class TimelineViewModelImpl: TimelineViewModel {

lazy var tweetsPublisher: AnyPublisher<[Tweet], Never> = $tweets.eraseToAnyPublisher()
@Published private var tweets: [Tweet] = []

private var tweetsMap: [String: Tweet] = [:] // [Tweet.id: Tweet]
private var tweetRepliesMap: [String: [String]] = [:] // [Tweet.id: [Tweet.id]]

private lazy var networks = Networks()

func fetchData() {
guard let filePath = Bundle.main.url(forResource: "timeline", withExtension: "json") else {
fatalError("Couldn't find the directory that has the data file!")
}

do {
let data = try Data(contentsOf: filePath)
let decoded = try JSONDecoder().decode(Timeline.self, from: data)
tweets = decoded.timeline.sorted(by: { $0.dateString.iso8601Date ?? Date() < $1.dateString.iso8601Date ?? Date() })

//print("Decoded the data: \(tweets)")

tweetsMap = Dictionary(uniqueKeysWithValues: tweets.map { ($0.id, $0) })

tweets.forEach { tweet in
if let replyTo = tweet.replyTo {
// Fetches the tweet that the input tweet replies to
tweet.tweetReplyTo = tweetsMap[replyTo]

// Maps the reply IDs to each tweet
tweetRepliesMap[replyTo, default: []].append(tweet.id)
}
}

tweets.forEach { tweet in
tweet.replies = fetchTweetReplies(on: tweet.id)
}

} catch {
print("Error decoding the data: \(error)")
}
}

func retrieveAvatar(in tweet: Tweet) async -> Data? {
guard let avatarLink = tweet.avatarLink else { return nil }

if let url = URL(string: avatarLink) {
let data = await networks.download(url: url)

tweet.avatarData = data
return data
}

return nil
}

// Returns the replies to the input tweet
private func fetchTweetReplies(on tweetID: String) -> [Tweet] {
guard let directReplyIDs = tweetRepliesMap[tweetID] else { return [] }

let replies = directReplyIDs.compactMap { tweetsMap[$0] }
return replies.sorted(by: { $0.dateString.iso8601Date ?? Date() < $1.dateString.iso8601Date ?? Date() })
}
}