Tips

Ready to (un)roll: build a thread reader with SwiftUI

By ‎@i_am_daniele‎
Thursday, 5 November 2020

Twitter threads are great when a single, concise thought is simply not enough. Threads are easy to create, but may be distracting to read. This prompted developers to create apps that enhance this experience; on iOS, Threader and Thread It For Twitter (an early adopter of this technique) are examples of nice reading and bookmarking apps built on the Twitter API.

In this tutorial, we’re focusing on iOS 14 using Swift 5.1; if you’re impatient, you can check the sample project on our TwitterDev Github. You’ll notice that the app is actually just a Share Extension built in SwiftUI, which allows us to invoke the reader directly from the Twitter app:

This Tweet is unavailable
This Tweet is unavailable.

With the new Twitter API, you can build a thread reader experience more efficiently. You can now request a conversation ID field for each Tweet payload and the entire conversation for that Tweet using recent search. Tweets are in a thread when they are to and from the same user, and when they share the same conversation ID, meaning that you’ll be able to unroll a thread from any part of it:

This Tweet is unavailable
This Tweet is unavailable.
URLQueryItem(
              name: "query",
              value: "from:\(authorId) to:\(authorId) conversation_id:\(conversationId)")

In the v2 Twitter API, each endpoint returns the same payload and accepts the same base query parameters. This makes it very easy to reuse the same code across your project. Let’s define a set of base parameters to request additional fields, such as poll metadata (also new in v2).

This Tweet is unavailable
This Tweet is unavailable.
let defaultParams = [
  URLQueryItem(name: "tweet.fields", value: "attachments,conversation_id,author_id,in_reply_to_user_id,entities,created_at"),
  URLQueryItem(name: "user.fields", value: "profile_image_url"),
  URLQueryItem(name: "media.fields", value: "url,preview_image_url"),
  URLQueryItem(name: "expansions", value: "attachments.media_keys,attachments.poll_ids,referenced_tweets.id,in_reply_to_user_id,author_id")
]

While we’re at it, we’re also expanding the author_id field into a fully fledged user object. This way, we can save precious calls and still render details about the author of the thread.

This Tweet is unavailable
This Tweet is unavailable.

Like the name suggests, recent search returns Tweets that are no older than seven days. Before pulling a thread using that endpoint, let’s make sure the original Tweet is publicly available and created a week ago or earlier:

This Tweet is unavailable
This Tweet is unavailable.
var url = self.requestURL(path: "/2/tweets")
url.queryItems?.append(URLQueryItem(name: "ids", value: self.id))
 
// Get the selected tweet
let result = Twitter.request(url: url.url!, cached: false)
 
  // Get the conversation ID from the selected tweet
  .flatMap { (response) -> Result<TweetLookupResponse?, TwitterRequestError> in
    guard let response = response,
          let tweet = response.data?.first else {
      return .failure(.tweetNotFound)
    }
    
    if tweet.id == tweet.conversationId {
      return .success(response)
    } else {
      var url = self.requestURL(path: "/2/tweets")
      url.queryItems?.append(URLQueryItem(name: "ids", value: tweet.conversationId))
      return Twitter.request(url: url.url!, cached: false)
    }
  }
  
  // Check that the conversation is recent
  .flatMap { (response) -> Result<TweetLookupResponse?, TwitterRequestError> in
    guard let response = response,
          let tweet = response.data?.first else {
      return .failure(.conversationNotFound)
    }
    
    self.conversationHead = tweet
    guard self.conversationHead.isOlderThanSevenDays == false else {
      return .failure(.tweetTooOld)
    }
    
    return .success(response)
 
  }

Note how the code takes advantage of Swift’s Result type to avoid relying too heavily on callbacks. Twitter.request method encapsulates the networking logic. Inside this method, a semaphore signals when a request is completed. Because all requests run asynchronously in a Grand Central Dispatch closure, you can both chain sequential requests and run multiple requests in parallel if needed.

Codable structures

Working with JSON in Swift is overwhelmingly pleasant, and the new Twitter API also makes it easy to model our native object against what’s returned in the payload. TweetLookupResponse is one of the Codable structs that we’ll define.

This Tweet is unavailable
This Tweet is unavailable.
struct TweetLookupResponse : Codable {
  struct Meta : Codable {
    var resultCount: Int
    var newestId: String
    var oldestId: String
    var nextToken: String?
  }
  
  var data: [Tweet]?
  var includes: Includes?
  var meta: Meta?
}
 
struct Tweet : Codable {
  var attachments: Attachments?
  var authorId: String
  var conversationId: String
  var createdAt: Date
  var entities: Entities?
  var id: String
  var inReplyToUserId: String?
  var referencedTweets: [ReferencedTweet]?
  var text: String
}

We’ll also extend these structs with convenience lookup methods to check if a Tweet contains an expanded attachment in the includes payload. Here’s how to check if a Tweet contains a poll:

This Tweet is unavailable
This Tweet is unavailable.
extension TweetLookupResponse {
  func poll(tweet: Tweet) -> Poll? {
    if let pollId = tweet.attachments?.pollIds?.first,
       let polls = self.includes?.polls {
      return polls.filter { $0.id == pollId }.first
    }
    return nil
  }
}

Recent search will only return Tweets created in the past seven days, so it’s a good idea to check that the thread is still fresh. The app checks that through a computed property. A simple and easy way that saves you unnecessary requests.

This Tweet is unavailable
This Tweet is unavailable.
var isOlderThanSevenDays: Bool {
  return abs(Calendar.current.dateComponents([.day], from: self.createdAt, to: Date()).day!) > 7
}

On the topic of optimizing things, the app also implements a straightforward caching mechanism.

You’ll notice that the app only caches search results. That allows it to optimize on Tweet usage caps and rate limiting by requesting the conversation only once. When serving cached content, the app checks that the conversation still exists by making an uncached Tweet lookup request:

This Tweet is unavailable
This Tweet is unavailable.
// Get the entire thread using recent search
.flatMap { (response) -> Result<TweetLookupResponse?, TwitterRequestError> in
  var url = self.requestURL(path: "/2/tweets/search/recent")
  url.queryItems? += [
    URLQueryItem(name: "query", value: "from:\(self.conversationHead.authorId) to:\(self.conversationHead.authorId) conversation_id:\(self.conversationHead.conversationId)"),
    URLQueryItem(name: "max_results", value: "100")
  ]
  return Twitter.request(url: url.url!)
}
 
// Get individual tweets from the thread.
// Since we're caching the search response, this is useful to check that
// each tweet in the thread still exists.
.flatMap { (response) -> Result<TweetLookupResponse?, TwitterRequestError> in
  var result: Result<TweetLookupResponse?, TwitterRequestError>!
  
  var url = self.requestURL(path: "/2/tweets")
  if let response = response,
     let data = response.data {
    var ids = data.map { $0.id }
    ids.append(self.conversationHead.id)
    url.queryItems?.append(URLQueryItem(name: "ids", value: ids.joined(separator: ",")))
    return Twitter.request(url: url.url!, cached: false)
  } else {
    result = .failure(.unknown)
    return result
  }
}

Thanks to SwiftUI, the rest is smooth sailing. The app defines a main ConversationView that renders all the parts of a Tweet:

This Tweet is unavailable
This Tweet is unavailable.
struct ConversationView: View {
  var thread: TweetLookupResponse
    var body: some View {
      ScrollView() {
        ThreadHeader(thread: thread)
        VStack(alignment: .leading, spacing: 10) {
          ForEach(thread.data!, id: \.id) { tweet in
            Text(formattedTweet(tweet)).fixedSize(horizontal: false, vertical: true).padding(5)
            TweetImages(media: thread.media(tweet: tweet))
 
            if let reference = tweet.referenceURL(type: .quoted) {
              TweetEmbed(reference: reference)
            }
            
            if let poll = thread.poll(tweet: tweet) {
              TweetPoll(poll: poll)
            }
          }
        }.padding()
      }
    }
}

Each component is well defined as a separate view, which allows flexibility in case you want to tweak the appearance of the thread. The app is simply instantiating a UIHostingController to serve the SwiftUI view from the Share Extension:

This Tweet is unavailable
This Tweet is unavailable.
let threadView = ConversationView(thread: response)
      let hostingController = UIHostingController(rootView: threadView)
      hostingController.view.translatesAutoresizingMaskIntoConstraints = false
      self.addChild(hostingController)
      self.view.addSubview(hostingController.view)
      hostingController.didMove(toParent: self)

This is all you need to create a thread reading experience using the latest iOS development stack. Once again, take a look at the finished project on Github. Let us know if you end up building it into your app!

This Tweet is unavailable
This Tweet is unavailable.