mirror of
https://github.com/Ranchero-Software/NetNewsWire
synced 2025-08-12 06:26:36 +00:00
Merge branch 'main' into ios-ui-settings-localised
# Conflicts: # NetNewsWire.xcodeproj/project.pbxproj # Shared/Timer/AccountRefreshTimer.swift # iOS/Account/ReaderAPIAccountViewController.swift
This commit is contained in:
@@ -653,6 +653,22 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
|
||||
fetchUnreadCounts(for: webFeeds, completion: completion)
|
||||
}
|
||||
|
||||
public func fetchUnreadArticlesBetween(limit: Int?, before: Date?, after: Date?) throws -> Set<Article> {
|
||||
return try fetchUnreadArticlesBetween(forContainer: self, limit: limit, before: before, after: after)
|
||||
}
|
||||
|
||||
public func fetchUnreadArticlesBetween(folder: Folder, limit: Int?, before: Date?, after: Date?) throws -> Set<Article> {
|
||||
return try fetchUnreadArticlesBetween(forContainer: folder, limit: limit, before: before, after: after)
|
||||
}
|
||||
|
||||
public func fetchUnreadArticlesBetween(webFeeds: Set<WebFeed>, limit: Int?, before: Date?, after: Date?) throws -> Set<Article> {
|
||||
return try fetchUnreadArticlesBetween(feeds: webFeeds, limit: limit, before: before, after: after)
|
||||
}
|
||||
|
||||
public func fetchArticlesBetween(articleIDs: Set<String>, before: Date?, after: Date?) throws -> Set<Article> {
|
||||
return try database.fetchArticlesBetween(articleIDs: articleIDs, before: before, after: after)
|
||||
}
|
||||
|
||||
public func fetchArticles(_ fetchType: FetchType) throws -> Set<Article> {
|
||||
switch fetchType {
|
||||
case .starred(let limit):
|
||||
@@ -1150,6 +1166,17 @@ private extension Account {
|
||||
return articles
|
||||
}
|
||||
|
||||
func fetchUnreadArticlesBetween(forContainer container: Container, limit: Int?, before: Date?, after: Date?) throws -> Set<Article> {
|
||||
let feeds = container.flattenedWebFeeds()
|
||||
let articles = try database.fetchUnreadArticlesBetween(feeds.webFeedIDs(), limit, before, after)
|
||||
return articles
|
||||
}
|
||||
|
||||
func fetchUnreadArticlesBetween(feeds: Set<WebFeed>, limit: Int?, before: Date?, after: Date?) throws -> Set<Article> {
|
||||
let articles = try database.fetchUnreadArticlesBetween(feeds.webFeedIDs(), limit, before, after)
|
||||
return articles
|
||||
}
|
||||
|
||||
func fetchUnreadArticlesAsync(forContainer container: Container, limit: Int?, _ completion: @escaping ArticleSetResultBlock) {
|
||||
let webFeeds = container.flattenedWebFeeds()
|
||||
database.fetchUnreadArticlesAsync(webFeeds.webFeedIDs(), limit) { [weak self] (articleSetResult) in
|
||||
|
||||
@@ -11,6 +11,7 @@ import RSCore
|
||||
import RSWeb
|
||||
import Articles
|
||||
import ArticlesDatabase
|
||||
import RSDatabase
|
||||
|
||||
// Main thread only.
|
||||
|
||||
@@ -325,6 +326,26 @@ public final class AccountManager: UnreadCountProvider {
|
||||
return false
|
||||
}
|
||||
|
||||
public func anyLocalOriCloudAccountHasAtLeastOneTwitterFeed() -> Bool {
|
||||
// We removed our Twitter code, and the ability to read feeds from Twitter,
|
||||
// when Twitter announced the end of the free tier for the Twitter API.
|
||||
// We are cheering on Twitter’s increasing irrelevancy.
|
||||
|
||||
for account in accounts {
|
||||
if account.type == .cloudKit || account.type == .onMyMac {
|
||||
for webfeed in account.flattenedWebFeeds() {
|
||||
if let components = URLComponents(string: webfeed.url), let host = components.host {
|
||||
if host == "twitter.com" { // Allow, for instance, blog.twitter.com, which might have an actual RSS feed
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Fetching Articles
|
||||
|
||||
// These fetch articles from active accounts and return a merged Set<Article>.
|
||||
@@ -340,33 +361,53 @@ public final class AccountManager: UnreadCountProvider {
|
||||
}
|
||||
|
||||
public func fetchArticlesAsync(_ fetchType: FetchType, _ completion: @escaping ArticleSetResultBlock) {
|
||||
precondition(Thread.isMainThread)
|
||||
|
||||
guard activeAccounts.count > 0 else {
|
||||
completion(.success(Set<Article>()))
|
||||
return
|
||||
}
|
||||
|
||||
var allFetchedArticles = Set<Article>()
|
||||
var databaseError: DatabaseError?
|
||||
let dispatchGroup = DispatchGroup()
|
||||
|
||||
for account in activeAccounts {
|
||||
|
||||
dispatchGroup.enter()
|
||||
|
||||
account.fetchArticlesAsync(fetchType) { (articleSetResult) in
|
||||
precondition(Thread.isMainThread)
|
||||
|
||||
switch articleSetResult {
|
||||
case .success(let articles):
|
||||
allFetchedArticles.formUnion(articles)
|
||||
case .failure(let error):
|
||||
databaseError = error
|
||||
}
|
||||
|
||||
dispatchGroup.leave()
|
||||
}
|
||||
}
|
||||
|
||||
dispatchGroup.notify(queue: .main) {
|
||||
if let databaseError {
|
||||
completion(.failure(databaseError))
|
||||
}
|
||||
else {
|
||||
completion(.success(allFetchedArticles))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func fetchUnreadArticlesBetween(limit: Int? = nil, before: Date? = nil, after: Date? = nil) throws -> Set<Article> {
|
||||
precondition(Thread.isMainThread)
|
||||
|
||||
var allFetchedArticles = Set<Article>()
|
||||
let numberOfAccounts = activeAccounts.count
|
||||
var accountsReporting = 0
|
||||
|
||||
guard numberOfAccounts > 0 else {
|
||||
completion(.success(allFetchedArticles))
|
||||
return
|
||||
}
|
||||
|
||||
var articles = Set<Article>()
|
||||
for account in activeAccounts {
|
||||
account.fetchArticlesAsync(fetchType) { (articleSetResult) in
|
||||
accountsReporting += 1
|
||||
|
||||
switch articleSetResult {
|
||||
case .success(let articles):
|
||||
allFetchedArticles.formUnion(articles)
|
||||
if accountsReporting == numberOfAccounts {
|
||||
completion(.success(allFetchedArticles))
|
||||
}
|
||||
case .failure(let databaseError):
|
||||
completion(.failure(databaseError))
|
||||
return
|
||||
}
|
||||
}
|
||||
articles.formUnion(try account.fetchUnreadArticlesBetween(limit: limit, before: before, after: after))
|
||||
}
|
||||
return articles
|
||||
}
|
||||
|
||||
// MARK: - Fetching Article Counts
|
||||
|
||||
@@ -15,6 +15,7 @@ public protocol ArticleFetcher {
|
||||
func fetchArticles() throws -> Set<Article>
|
||||
func fetchArticlesAsync(_ completion: @escaping ArticleSetResultBlock)
|
||||
func fetchUnreadArticles() throws -> Set<Article>
|
||||
func fetchUnreadArticlesBetween(before: Date?, after: Date?) throws -> Set<Article>
|
||||
func fetchUnreadArticlesAsync(_ completion: @escaping ArticleSetResultBlock)
|
||||
}
|
||||
|
||||
@@ -37,6 +38,10 @@ extension WebFeed: ArticleFetcher {
|
||||
return try fetchArticles().unreadArticles()
|
||||
}
|
||||
|
||||
public func fetchUnreadArticlesBetween(before: Date? = nil, after: Date? = nil) throws -> Set<Article> {
|
||||
return try account?.fetchUnreadArticlesBetween(webFeeds: [self], limit: nil, before: before, after: after) ?? Set<Article>()
|
||||
}
|
||||
|
||||
public func fetchUnreadArticlesAsync(_ completion: @escaping ArticleSetResultBlock) {
|
||||
guard let account = account else {
|
||||
assertionFailure("Expected feed.account, but got nil.")
|
||||
@@ -81,6 +86,14 @@ extension Folder: ArticleFetcher {
|
||||
return try account.fetchArticles(.folder(self, true))
|
||||
}
|
||||
|
||||
public func fetchUnreadArticlesBetween(before: Date? = nil, after: Date? = nil) throws -> Set<Article> {
|
||||
guard let account = account else {
|
||||
assertionFailure("Expected folder.account, but got nil.")
|
||||
return Set<Article>()
|
||||
}
|
||||
return try account.fetchUnreadArticlesBetween(folder: self, limit: nil, before: before, after: after)
|
||||
}
|
||||
|
||||
public func fetchUnreadArticlesAsync(_ completion: @escaping ArticleSetResultBlock) {
|
||||
guard let account = account else {
|
||||
assertionFailure("Expected folder.account, but got nil.")
|
||||
|
||||
@@ -136,7 +136,7 @@ private extension CloudKitArticlesZoneDelegate {
|
||||
account?.markAsStarred(updateableStarredArticleIDs) { databaseError in
|
||||
if let databaseError = databaseError {
|
||||
errorOccurred = true
|
||||
self.logger.error("Error occurred while stroing starred records: \(databaseError.localizedDescription, privacy: .public)")
|
||||
self.logger.error("Error occurred while storing starred records: \(databaseError.localizedDescription, privacy: .public)")
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
//
|
||||
// TwitterEntities.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 4/18/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol TwitterEntity {
|
||||
var indices: [Int]? { get }
|
||||
func renderAsHTML() -> String
|
||||
}
|
||||
|
||||
extension TwitterEntity {
|
||||
|
||||
var startIndex: Int {
|
||||
if let indices = indices, indices.count > 0 {
|
||||
return indices[0]
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
var endIndex: Int {
|
||||
if let indices = indices, indices.count > 1 {
|
||||
return indices[1]
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct TwitterEntities: Codable {
|
||||
|
||||
let hashtags: [TwitterHashtag]?
|
||||
let urls: [TwitterURL]?
|
||||
let userMentions: [TwitterMention]?
|
||||
let symbols: [TwitterSymbol]?
|
||||
let media: [TwitterMedia]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case hashtags = "hashtags"
|
||||
case urls = "urls"
|
||||
case userMentions = "user_mentions"
|
||||
case symbols = "symbols"
|
||||
case media = "media"
|
||||
}
|
||||
|
||||
func combineAndSort() -> [TwitterEntity] {
|
||||
var entities = [TwitterEntity]()
|
||||
if let hashtags = hashtags {
|
||||
entities.append(contentsOf: hashtags)
|
||||
}
|
||||
if let urls = urls {
|
||||
entities.append(contentsOf: urls)
|
||||
}
|
||||
if let userMentions = userMentions {
|
||||
entities.append(contentsOf: userMentions)
|
||||
}
|
||||
if let symbols = symbols {
|
||||
entities.append(contentsOf: symbols)
|
||||
}
|
||||
if let media = media {
|
||||
entities.append(contentsOf: media)
|
||||
}
|
||||
return entities.sorted(by: { $0.startIndex < $1.startIndex })
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
//
|
||||
// TwitterExtendedEntities.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 4/18/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct TwitterExtendedEntities: Codable {
|
||||
|
||||
let medias: [TwitterExtendedMedia]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case medias = "media"
|
||||
}
|
||||
|
||||
func renderAsHTML() -> String {
|
||||
var html = String()
|
||||
if let medias = medias {
|
||||
for media in medias {
|
||||
html += media.renderAsHTML()
|
||||
}
|
||||
}
|
||||
return html
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
//
|
||||
// TwitterExtendedMedia.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 4/18/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct TwitterExtendedMedia: Codable {
|
||||
|
||||
let idStr: String?
|
||||
let indices: [Int]?
|
||||
let mediaURL: String?
|
||||
let httpsMediaURL: String?
|
||||
let url: String?
|
||||
let displayURL: String?
|
||||
let type: String?
|
||||
let video: TwitterVideo?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case idStr = "idStr"
|
||||
case indices = "indices"
|
||||
case mediaURL = "media_url"
|
||||
case httpsMediaURL = "media_url_https"
|
||||
case url = "url"
|
||||
case displayURL = "display_url"
|
||||
case type = "type"
|
||||
case video = "video_info"
|
||||
}
|
||||
|
||||
func renderAsHTML() -> String {
|
||||
var html = String()
|
||||
|
||||
switch type {
|
||||
case "photo":
|
||||
html += renderPhotoAsHTML()
|
||||
case "video":
|
||||
html += renderVideoAsHTML()
|
||||
case "animated_gif":
|
||||
html += renderAnimatedGIFAsHTML()
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension TwitterExtendedMedia {
|
||||
|
||||
func renderPhotoAsHTML() -> String {
|
||||
if let httpsMediaURL = httpsMediaURL {
|
||||
return "<figure><img src=\"\(httpsMediaURL)\"></figure>"
|
||||
}
|
||||
if let mediaURL = mediaURL {
|
||||
return "<figure><img src=\"\(mediaURL)\"></figure>"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func renderVideoAsHTML() -> String {
|
||||
guard let bestVariantURL = findBestVariant()?.url else { return "" }
|
||||
|
||||
var html = "<video "
|
||||
|
||||
if let httpsMediaURL = httpsMediaURL {
|
||||
html += "poster=\"\(httpsMediaURL)\" "
|
||||
} else if let mediaURL = mediaURL {
|
||||
html += "poster=\"\(mediaURL)\" "
|
||||
}
|
||||
|
||||
html += "src=\"\(bestVariantURL)\"></video>"
|
||||
return html
|
||||
}
|
||||
|
||||
func renderAnimatedGIFAsHTML() -> String {
|
||||
guard let bestVariantURL = findBestVariant()?.url else { return "" }
|
||||
|
||||
var html = "<video class=\"nnwAnimatedGIF\" "
|
||||
|
||||
if let httpsMediaURL = httpsMediaURL {
|
||||
html += "poster=\"\(httpsMediaURL)\" "
|
||||
} else if let mediaURL = mediaURL {
|
||||
html += "poster=\"\(mediaURL)\" "
|
||||
}
|
||||
|
||||
html += "src=\"\(bestVariantURL)\" autoplay muted loop></video>"
|
||||
return html
|
||||
}
|
||||
|
||||
func findBestVariant() -> TwitterVideo.Variant? {
|
||||
var best: TwitterVideo.Variant? = nil
|
||||
if let variants = video?.variants {
|
||||
for variant in variants {
|
||||
if let currentBest = best {
|
||||
if variant.bitrate ?? 0 > currentBest.bitrate ?? 0 {
|
||||
best = variant
|
||||
}
|
||||
} else {
|
||||
best = variant
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,485 +0,0 @@
|
||||
//
|
||||
// TwitterFeedProvider.swift
|
||||
// FeedProvider
|
||||
//
|
||||
// Created by Maurice Parker on 4/7/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Secrets
|
||||
import OAuthSwift
|
||||
import RSParser
|
||||
import RSWeb
|
||||
|
||||
public enum TwitterFeedProviderError: LocalizedError {
|
||||
case rateLimitExceeded
|
||||
case screenNameNotFound
|
||||
case unknown
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .rateLimitExceeded:
|
||||
return NSLocalizedString("Twitter API rate limit has been exceeded. Please wait a short time and try again.", comment: "Rate Limit")
|
||||
case .screenNameNotFound:
|
||||
return NSLocalizedString("Unable to determine screen name.", comment: "Screen name")
|
||||
case .unknown:
|
||||
return NSLocalizedString("An unknown Twitter Feed Provider error has occurred.", comment: "Unknown error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum TwitterFeedType: Int {
|
||||
case homeTimeline = 0
|
||||
case mentions = 1
|
||||
case screenName = 2
|
||||
case search = 3
|
||||
}
|
||||
|
||||
public final class TwitterFeedProvider: FeedProvider {
|
||||
|
||||
private static let homeURL = "https://www.twitter.com"
|
||||
private static let iconURL = "https://abs.twimg.com/favicons/twitter.ico"
|
||||
private static let server = "api.twitter.com"
|
||||
private static let apiBase = "https://api.twitter.com/1.1/"
|
||||
private static let userAgentHeaders = UserAgent.headers() as! [String: String]
|
||||
private static let dateFormat = "EEE MMM dd HH:mm:ss Z yyyy"
|
||||
|
||||
private static let userPaths = ["/", "/home", "/notifications"]
|
||||
private static let reservedPaths = ["/search", "/explore", "/messages", "/i", "/compose", "/notifications/mentions"]
|
||||
|
||||
private var parsingQueue = DispatchQueue(label: "TwitterFeedProvider parse queue")
|
||||
|
||||
public var screenName: String
|
||||
|
||||
private var oauthToken: String
|
||||
private var oauthTokenSecret: String
|
||||
|
||||
private var client: OAuthSwiftClient
|
||||
|
||||
private var rateLimitRemaining: Int?
|
||||
private var rateLimitReset: Date?
|
||||
|
||||
public init?(tokenSuccess: OAuthSwift.TokenSuccess) {
|
||||
guard let screenName = tokenSuccess.parameters["screen_name"] as? String else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.screenName = screenName
|
||||
self.oauthToken = tokenSuccess.credential.oauthToken
|
||||
self.oauthTokenSecret = tokenSuccess.credential.oauthTokenSecret
|
||||
|
||||
let tokenCredentials = Credentials(type: .oauthAccessToken, username: screenName, secret: oauthToken)
|
||||
try? CredentialsManager.storeCredentials(tokenCredentials, server: Self.server)
|
||||
|
||||
let tokenSecretCredentials = Credentials(type: .oauthAccessTokenSecret, username: screenName, secret: oauthTokenSecret)
|
||||
try? CredentialsManager.storeCredentials(tokenSecretCredentials, server: Self.server)
|
||||
|
||||
client = OAuthSwiftClient(consumerKey: SecretsManager.provider.twitterConsumerKey,
|
||||
consumerSecret: SecretsManager.provider.twitterConsumerSecret,
|
||||
oauthToken: oauthToken,
|
||||
oauthTokenSecret: oauthTokenSecret,
|
||||
version: .oauth1)
|
||||
}
|
||||
|
||||
public init?(screenName: String) {
|
||||
self.screenName = screenName
|
||||
|
||||
guard let tokenCredentials = try? CredentialsManager.retrieveCredentials(type: .oauthAccessToken, server: Self.server, username: screenName),
|
||||
let tokenSecretCredentials = try? CredentialsManager.retrieveCredentials(type: .oauthAccessTokenSecret, server: Self.server, username: screenName) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.oauthToken = tokenCredentials.secret
|
||||
self.oauthTokenSecret = tokenSecretCredentials.secret
|
||||
|
||||
client = OAuthSwiftClient(consumerKey: SecretsManager.provider.twitterConsumerKey,
|
||||
consumerSecret: SecretsManager.provider.twitterConsumerSecret,
|
||||
oauthToken: oauthToken,
|
||||
oauthTokenSecret: oauthTokenSecret,
|
||||
version: .oauth1)
|
||||
}
|
||||
|
||||
public func ability(_ urlComponents: URLComponents) -> FeedProviderAbility {
|
||||
guard urlComponents.host?.hasSuffix("twitter.com") ?? false else {
|
||||
return .none
|
||||
}
|
||||
|
||||
if let username = urlComponents.user {
|
||||
if username == screenName {
|
||||
return .owner
|
||||
} else {
|
||||
return .none
|
||||
}
|
||||
}
|
||||
|
||||
return .available
|
||||
}
|
||||
|
||||
public func iconURL(_ urlComponents: URLComponents, completion: @escaping (Result<String, Error>) -> Void) {
|
||||
if let screenName = deriveScreenName(urlComponents) {
|
||||
retrieveUser(screenName: screenName) { result in
|
||||
switch result {
|
||||
case .success(let user):
|
||||
if let avatarURL = user.avatarURL {
|
||||
completion(.success(avatarURL))
|
||||
} else {
|
||||
completion(.failure(TwitterFeedProviderError.screenNameNotFound))
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
completion(.success(Self.iconURL))
|
||||
}
|
||||
}
|
||||
|
||||
public func metaData(_ urlComponents: URLComponents, completion: @escaping (Result<FeedProviderFeedMetaData, Error>) -> Void) {
|
||||
switch urlComponents.path {
|
||||
|
||||
case "", "/", "/home":
|
||||
let name = NSLocalizedString("Twitter Timeline", comment: "Twitter Timeline")
|
||||
completion(.success(FeedProviderFeedMetaData(name: name, homePageURL: Self.homeURL)))
|
||||
|
||||
case "/notifications/mentions":
|
||||
let name = NSLocalizedString("Twitter Mentions", comment: "Twitter Mentions")
|
||||
completion(.success(FeedProviderFeedMetaData(name: name, homePageURL: Self.homeURL)))
|
||||
|
||||
case "/search":
|
||||
if let query = urlComponents.queryItems?.first(where: { $0.name == "q" })?.value {
|
||||
let localized = NSLocalizedString("Twitter Search: %@", comment: "Twitter Search")
|
||||
let name = NSString.localizedStringWithFormat(localized as NSString, query) as String
|
||||
completion(.success(FeedProviderFeedMetaData(name: name, homePageURL: Self.homeURL)))
|
||||
} else {
|
||||
let name = NSLocalizedString("Twitter Search", comment: "Twitter Search")
|
||||
completion(.success(FeedProviderFeedMetaData(name: name, homePageURL: Self.homeURL)))
|
||||
}
|
||||
|
||||
default:
|
||||
if let hashtag = deriveHashtag(urlComponents) {
|
||||
completion(.success(FeedProviderFeedMetaData(name: "#\(hashtag)", homePageURL: Self.homeURL)))
|
||||
} else if let listID = deriveListID(urlComponents) {
|
||||
retrieveList(listID: listID) { result in
|
||||
switch result {
|
||||
case .success(let list):
|
||||
if let userName = list.name {
|
||||
var urlComponents = URLComponents(string: Self.homeURL)
|
||||
urlComponents?.path = "/i/lists/\(listID)"
|
||||
completion(.success(FeedProviderFeedMetaData(name: userName, homePageURL: urlComponents?.url?.absoluteString)))
|
||||
} else {
|
||||
completion(.failure(TwitterFeedProviderError.screenNameNotFound))
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
} else if let screenName = deriveScreenName(urlComponents) {
|
||||
retrieveUser(screenName: screenName) { result in
|
||||
switch result {
|
||||
case .success(let user):
|
||||
if let userName = user.name {
|
||||
var urlComponents = URLComponents(string: Self.homeURL)
|
||||
urlComponents?.path = "/\(screenName)"
|
||||
completion(.success(FeedProviderFeedMetaData(name: userName, homePageURL: urlComponents?.url?.absoluteString)))
|
||||
} else {
|
||||
completion(.failure(TwitterFeedProviderError.screenNameNotFound))
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
completion(.failure(TwitterFeedProviderError.unknown))
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
public func refresh(_ webFeed: WebFeed, completion: @escaping (Result<Set<ParsedItem>, Error>) -> Void) {
|
||||
guard let urlComponents = URLComponents(string: webFeed.url) else {
|
||||
completion(.failure(TwitterFeedProviderError.unknown))
|
||||
return
|
||||
}
|
||||
|
||||
let api: String
|
||||
var parameters = [String: Any]()
|
||||
var isSearch = false
|
||||
|
||||
parameters["count"] = 20
|
||||
|
||||
switch urlComponents.path {
|
||||
case "", "/", "/home":
|
||||
parameters["count"] = 100
|
||||
if let sinceToken = webFeed.sinceToken, let sinceID = Int(sinceToken) {
|
||||
parameters["since_id"] = sinceID
|
||||
}
|
||||
api = "statuses/home_timeline.json"
|
||||
case "/notifications/mentions":
|
||||
api = "statuses/mentions_timeline.json"
|
||||
case "/search":
|
||||
api = "search/tweets.json"
|
||||
if let query = urlComponents.queryItems?.first(where: { $0.name == "q" })?.value {
|
||||
parameters["q"] = query
|
||||
}
|
||||
isSearch = true
|
||||
default:
|
||||
if let hashtag = deriveHashtag(urlComponents) {
|
||||
api = "search/tweets.json"
|
||||
parameters["q"] = "#\(hashtag)"
|
||||
isSearch = true
|
||||
} else if let listID = deriveListID(urlComponents) {
|
||||
api = "lists/statuses.json"
|
||||
parameters["list_id"] = listID
|
||||
} else if let screenName = deriveScreenName(urlComponents) {
|
||||
api = "statuses/user_timeline.json"
|
||||
parameters["exclude_replies"] = true
|
||||
parameters["screen_name"] = screenName
|
||||
} else {
|
||||
completion(.failure(TwitterFeedProviderError.unknown))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
retrieveTweets(api: api, parameters: parameters, isSearch: isSearch) { result in
|
||||
switch result {
|
||||
case .success(let statuses):
|
||||
if let sinceID = statuses.first?.idStr {
|
||||
webFeed.sinceToken = sinceID
|
||||
}
|
||||
self.parsingQueue.async {
|
||||
let parsedItems = self.makeParsedItems(webFeed.url, statuses)
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(parsedItems))
|
||||
}
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static func buildURL(_ type: TwitterFeedType, username: String?, screenName: String?, searchField: String?) -> URL? {
|
||||
var components = URLComponents()
|
||||
components.scheme = "https"
|
||||
components.host = "twitter.com"
|
||||
|
||||
switch type {
|
||||
case .homeTimeline:
|
||||
guard let username = username else {
|
||||
return nil
|
||||
}
|
||||
components.user = username
|
||||
case .mentions:
|
||||
guard let username = username else {
|
||||
return nil
|
||||
}
|
||||
components.user = username
|
||||
components.path = "/notifications/mentions"
|
||||
case .screenName:
|
||||
guard let screenName = screenName else {
|
||||
return nil
|
||||
}
|
||||
components.path = "/\(screenName)"
|
||||
case .search:
|
||||
guard let searchField = searchField else {
|
||||
return nil
|
||||
}
|
||||
components.path = "/search"
|
||||
components.queryItems = [URLQueryItem(name: "q", value: searchField)]
|
||||
}
|
||||
|
||||
return components.url
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: OAuth1SwiftProvider
|
||||
|
||||
extension TwitterFeedProvider: OAuth1SwiftProvider {
|
||||
|
||||
public static var callbackURL: URL {
|
||||
return URL(string: "netnewswire://")!
|
||||
}
|
||||
|
||||
public static var oauth1Swift: OAuth1Swift {
|
||||
return OAuth1Swift(
|
||||
consumerKey: SecretsManager.provider.twitterConsumerKey,
|
||||
consumerSecret: SecretsManager.provider.twitterConsumerSecret,
|
||||
requestTokenUrl: "https://api.twitter.com/oauth/request_token",
|
||||
authorizeUrl: "https://api.twitter.com/oauth/authorize",
|
||||
accessTokenUrl: "https://api.twitter.com/oauth/access_token"
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private extension TwitterFeedProvider {
|
||||
|
||||
func deriveHashtag(_ urlComponents: URLComponents) -> String? {
|
||||
let path = urlComponents.path
|
||||
if path.starts(with: "/hashtag/"), let startIndex = path.index(path.startIndex, offsetBy: 9, limitedBy: path.endIndex), startIndex < path.endIndex {
|
||||
return String(path[startIndex..<path.endIndex])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deriveScreenName(_ urlComponents: URLComponents) -> String? {
|
||||
let path = urlComponents.path
|
||||
guard !Self.reservedPaths.contains(path) else { return nil }
|
||||
|
||||
if path.isEmpty || Self.userPaths.contains(path) {
|
||||
return screenName
|
||||
} else {
|
||||
return String(path.suffix(from: path.index(path.startIndex, offsetBy: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
func deriveListID(_ urlComponents: URLComponents) -> String? {
|
||||
let path = urlComponents.path
|
||||
guard path.starts(with: "/i/lists/") else { return nil }
|
||||
return String(path.suffix(from: path.index(path.startIndex, offsetBy: 9)))
|
||||
}
|
||||
|
||||
func retrieveUser(screenName: String, completion: @escaping (Result<TwitterUser, Error>) -> Void) {
|
||||
let url = "\(Self.apiBase)users/show.json"
|
||||
let parameters = ["screen_name": screenName]
|
||||
|
||||
client.get(url, parameters: parameters, headers: Self.userAgentHeaders) { result in
|
||||
switch result {
|
||||
case .success(let response):
|
||||
let decoder = JSONDecoder()
|
||||
do {
|
||||
let user = try decoder.decode(TwitterUser.self, from: response.data)
|
||||
completion(.success(user))
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func retrieveList(listID: String, completion: @escaping (Result<TwitterList, Error>) -> Void) {
|
||||
let url = "\(Self.apiBase)lists/show.json"
|
||||
let parameters = ["list_id": listID]
|
||||
|
||||
client.get(url, parameters: parameters, headers: Self.userAgentHeaders) { result in
|
||||
switch result {
|
||||
case .success(let response):
|
||||
let decoder = JSONDecoder()
|
||||
do {
|
||||
let collection = try decoder.decode(TwitterList.self, from: response.data)
|
||||
completion(.success(collection))
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func retrieveTweets(api: String, parameters: [String: Any], isSearch: Bool, completion: @escaping (Result<[TwitterStatus], Error>) -> Void) {
|
||||
let url = "\(Self.apiBase)\(api)"
|
||||
var expandedParameters = parameters
|
||||
expandedParameters["tweet_mode"] = "extended"
|
||||
|
||||
if let remaining = rateLimitRemaining, let reset = rateLimitReset, remaining < 1 && reset > Date() {
|
||||
completion(.failure(TwitterFeedProviderError.rateLimitExceeded))
|
||||
return
|
||||
}
|
||||
|
||||
client.get(url, parameters: expandedParameters, headers: Self.userAgentHeaders) { result in
|
||||
switch result {
|
||||
case .success(let response):
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.locale = Locale.init(identifier: "en_US_POSIX")
|
||||
dateFormatter.dateFormat = Self.dateFormat
|
||||
decoder.dateDecodingStrategy = .formatted(dateFormatter)
|
||||
|
||||
if let remaining = response.response.value(forHTTPHeaderField: "x-rate-limit-remaining") {
|
||||
self.rateLimitRemaining = Int(remaining) ?? 0
|
||||
}
|
||||
if let reset = response.response.value(forHTTPHeaderField: "x-rate-limit-reset") {
|
||||
self.rateLimitReset = Date(timeIntervalSince1970: Double(reset) ?? 0)
|
||||
}
|
||||
|
||||
self.parsingQueue.async {
|
||||
do {
|
||||
let tweets: [TwitterStatus]
|
||||
if isSearch {
|
||||
let searchResult = try decoder.decode(TwitterSearchResult.self, from: response.data)
|
||||
if let statuses = searchResult.statuses {
|
||||
tweets = statuses
|
||||
} else {
|
||||
tweets = [TwitterStatus]()
|
||||
}
|
||||
} else {
|
||||
tweets = try decoder.decode([TwitterStatus].self, from: response.data)
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(tweets))
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
if error.errorCode == -11 {
|
||||
// Eat these errors. They are old or invalid URL requests.
|
||||
completion(.success([TwitterStatus]()))
|
||||
} else {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func makeParsedItems(_ webFeedURL: String, _ statuses: [TwitterStatus]) -> Set<ParsedItem> {
|
||||
var parsedItems = Set<ParsedItem>()
|
||||
|
||||
for status in statuses {
|
||||
guard let idStr = status.idStr, let statusURL = status.url else { continue }
|
||||
|
||||
let parsedItem = ParsedItem(syncServiceID: nil,
|
||||
uniqueID: idStr,
|
||||
feedURL: webFeedURL,
|
||||
url: statusURL,
|
||||
externalURL: nil,
|
||||
title: nil,
|
||||
language: nil,
|
||||
contentHTML: status.renderAsHTML(),
|
||||
contentText: status.renderAsText(),
|
||||
summary: nil,
|
||||
imageURL: nil,
|
||||
bannerImageURL: nil,
|
||||
datePublished: status.createdAt,
|
||||
dateModified: nil,
|
||||
authors: makeParsedAuthors(status.user),
|
||||
tags: nil,
|
||||
attachments: nil)
|
||||
parsedItems.insert(parsedItem)
|
||||
}
|
||||
|
||||
return parsedItems
|
||||
}
|
||||
|
||||
func makeUserURL(_ screenName: String) -> String {
|
||||
return "https://twitter.com/\(screenName)"
|
||||
}
|
||||
|
||||
func makeParsedAuthors(_ user: TwitterUser?) -> Set<ParsedAuthor>? {
|
||||
guard let user = user else { return nil }
|
||||
return Set([ParsedAuthor(name: user.name, url: user.url, avatarURL: user.avatarURL, emailAddress: nil)])
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
//
|
||||
// TwitterHashtag.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 4/18/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct TwitterHashtag: Codable, TwitterEntity {
|
||||
|
||||
let text: String?
|
||||
let indices: [Int]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case text = "text"
|
||||
case indices = "indices"
|
||||
}
|
||||
|
||||
func renderAsHTML() -> String {
|
||||
var html = String()
|
||||
if let text = text {
|
||||
html += "<a href=\"https://twitter.com/search?q=%23\(text)\">#\(text)</a>"
|
||||
}
|
||||
return html
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
//
|
||||
// TwitterList.swift
|
||||
//
|
||||
//
|
||||
// Created by Maurice Parker on 8/14/20.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct TwitterList: Codable {
|
||||
|
||||
let name: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name = "name"
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
//
|
||||
// TwitterMedia.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 4/20/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct TwitterMedia: Codable, TwitterEntity {
|
||||
|
||||
let indices: [Int]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case indices = "indices"
|
||||
}
|
||||
|
||||
func renderAsHTML() -> String {
|
||||
return String()
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
//
|
||||
// TwitterMention.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 4/18/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct TwitterMention: Codable, TwitterEntity {
|
||||
|
||||
let name: String?
|
||||
let indices: [Int]?
|
||||
let screenName: String?
|
||||
let idStr: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name = "url"
|
||||
case indices = "indices"
|
||||
case screenName = "screen_name"
|
||||
case idStr = "idStr"
|
||||
}
|
||||
|
||||
func renderAsHTML() -> String {
|
||||
var html = String()
|
||||
if let screenName = screenName {
|
||||
html += "<a href=\"https://twitter.com/\(screenName)\">@\(screenName)</a>"
|
||||
}
|
||||
return html
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
//
|
||||
// TwitterSearchResult.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 4/18/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct TwitterSearchResult: Codable {
|
||||
|
||||
let statuses: [TwitterStatus]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case statuses = "statuses"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
//
|
||||
// TwitterStatus.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 4/16/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
final class TwitterStatus: Codable {
|
||||
|
||||
let createdAt: Date?
|
||||
let idStr: String?
|
||||
let fullText: String?
|
||||
let displayTextRange: [Int]?
|
||||
let user: TwitterUser?
|
||||
let truncated: Bool?
|
||||
let retweeted: Bool?
|
||||
let retweetedStatus: TwitterStatus?
|
||||
let quotedStatus: TwitterStatus?
|
||||
let entities: TwitterEntities?
|
||||
let extendedEntities: TwitterExtendedEntities?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case createdAt = "created_at"
|
||||
case idStr = "id_str"
|
||||
case fullText = "full_text"
|
||||
case displayTextRange = "display_text_range"
|
||||
case user = "user"
|
||||
case truncated = "truncated"
|
||||
case retweeted = "retweeted"
|
||||
case retweetedStatus = "retweeted_status"
|
||||
case quotedStatus = "quoted_status"
|
||||
case entities = "entities"
|
||||
case extendedEntities = "extended_entities"
|
||||
}
|
||||
|
||||
var url: String? {
|
||||
guard let userURL = user?.url, let idStr = idStr else { return nil }
|
||||
return "\(userURL)/status/\(idStr)"
|
||||
}
|
||||
|
||||
func renderAsText() -> String? {
|
||||
let statusToRender = retweetedStatus != nil ? retweetedStatus! : self
|
||||
return statusToRender.displayText
|
||||
}
|
||||
|
||||
func renderAsHTML(topLevel: Bool = true) -> String {
|
||||
if let retweetedStatus = retweetedStatus {
|
||||
return renderAsRetweetHTML(retweetedStatus)
|
||||
}
|
||||
if let quotedStatus = quotedStatus {
|
||||
return renderAsQuoteHTML(quotedStatus, topLevel: topLevel)
|
||||
}
|
||||
return renderAsOriginalHTML(topLevel: topLevel)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension TwitterStatus {
|
||||
|
||||
var displayText: String? {
|
||||
if let text = fullText, let displayRange = displayTextRange, displayRange.count > 1,
|
||||
let startIndex = text.index(text.startIndex, offsetBy: displayRange[0], limitedBy: text.endIndex),
|
||||
let endIndex = text.index(text.startIndex, offsetBy: displayRange[1], limitedBy: text.endIndex) {
|
||||
return String(text[startIndex..<endIndex])
|
||||
} else {
|
||||
return fullText
|
||||
}
|
||||
}
|
||||
|
||||
var displayHTML: String? {
|
||||
if let text = fullText, let displayRange = displayTextRange, displayRange.count > 1, let entities = entities?.combineAndSort() {
|
||||
|
||||
let displayStartIndex = text.index(text.startIndex, offsetBy: displayRange[0], limitedBy: text.endIndex) ?? text.startIndex
|
||||
let displayEndIndex = text.index(text.startIndex, offsetBy: displayRange[1], limitedBy: text.endIndex) ?? text.endIndex
|
||||
|
||||
var html = String()
|
||||
var prevIndex = displayStartIndex
|
||||
var unicodeScalarOffset = 0
|
||||
|
||||
for entity in entities {
|
||||
|
||||
// The twitter indices are messed up by characters with more than one scalar, we are going to adjust for that here.
|
||||
let endIndex = text.index(text.startIndex, offsetBy: entity.endIndex, limitedBy: text.endIndex) ?? text.endIndex
|
||||
if prevIndex < endIndex {
|
||||
let characters = String(text[prevIndex..<endIndex])
|
||||
for character in characters {
|
||||
unicodeScalarOffset += character.unicodeScalars.count - 1
|
||||
}
|
||||
}
|
||||
|
||||
let offsetStartIndex = unicodeScalarOffset < entity.startIndex ? entity.startIndex - unicodeScalarOffset : entity.startIndex
|
||||
let offsetEndIndex = unicodeScalarOffset < entity.endIndex ? entity.endIndex - unicodeScalarOffset : entity.endIndex
|
||||
|
||||
let entityStartIndex = text.index(text.startIndex, offsetBy: offsetStartIndex, limitedBy: text.endIndex) ?? text.startIndex
|
||||
let entityEndIndex = text.index(text.startIndex, offsetBy: offsetEndIndex, limitedBy: text.endIndex) ?? text.endIndex
|
||||
|
||||
if prevIndex < entityStartIndex {
|
||||
html += String(text[prevIndex..<entityStartIndex]).replacingOccurrences(of: "\n", with: "<br>")
|
||||
}
|
||||
|
||||
// We drop off any URL which is just pointing to the quoted status. It is redundant.
|
||||
if let twitterURL = entity as? TwitterURL, let expandedURL = twitterURL.expandedURL, let quotedURL = quotedStatus?.url {
|
||||
if expandedURL.caseInsensitiveCompare(quotedURL) != .orderedSame {
|
||||
html += entity.renderAsHTML()
|
||||
}
|
||||
} else {
|
||||
html += entity.renderAsHTML()
|
||||
}
|
||||
|
||||
prevIndex = entityEndIndex
|
||||
|
||||
}
|
||||
|
||||
if prevIndex < displayEndIndex {
|
||||
html += String(text[prevIndex..<displayEndIndex]).replacingOccurrences(of: "\n", with: "<br>")
|
||||
}
|
||||
|
||||
return html
|
||||
} else {
|
||||
return displayText
|
||||
}
|
||||
}
|
||||
|
||||
func renderAsTweetHTML(_ status: TwitterStatus, topLevel: Bool) -> String {
|
||||
var html = "<div>\(status.displayHTML ?? "")</div>"
|
||||
|
||||
if !topLevel, let createdAt = status.createdAt, let url = status.url {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateStyle = .medium
|
||||
dateFormatter.timeStyle = .short
|
||||
html += "<a href=\"\(url)\" class=\"twitterTimestamp\">\(dateFormatter.string(from: createdAt))</a>"
|
||||
}
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
func renderAsOriginalHTML(topLevel: Bool) -> String {
|
||||
var html = renderAsTweetHTML(self, topLevel: topLevel)
|
||||
html += extendedEntities?.renderAsHTML() ?? ""
|
||||
return html
|
||||
}
|
||||
|
||||
func renderAsRetweetHTML(_ status: TwitterStatus) -> String {
|
||||
var html = "<blockquote>"
|
||||
if let userHTML = status.user?.renderAsHTML() {
|
||||
html += userHTML
|
||||
}
|
||||
html += status.renderAsHTML(topLevel: false)
|
||||
html += "</blockquote>"
|
||||
return html
|
||||
}
|
||||
|
||||
func renderAsQuoteHTML(_ quotedStatus: TwitterStatus, topLevel: Bool) -> String {
|
||||
var html = String()
|
||||
html += renderAsTweetHTML(self, topLevel: topLevel)
|
||||
html += extendedEntities?.renderAsHTML() ?? ""
|
||||
html += "<blockquote>"
|
||||
if let userHTML = quotedStatus.user?.renderAsHTML() {
|
||||
html += userHTML
|
||||
}
|
||||
html += quotedStatus.renderAsHTML(topLevel: false)
|
||||
html += "</blockquote>"
|
||||
return html
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
//
|
||||
// TwitterSymbol.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 4/18/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct TwitterSymbol: Codable, TwitterEntity {
|
||||
|
||||
let text: String?
|
||||
let indices: [Int]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case text = "text"
|
||||
case indices = "indices"
|
||||
}
|
||||
|
||||
func renderAsHTML() -> String {
|
||||
var html = String()
|
||||
if let text = text {
|
||||
html += "<a href=\"https://twitter.com/search?q=%24\(text)\">$\(text)</a>"
|
||||
}
|
||||
return html
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
//
|
||||
// TwitterURL.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 4/18/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct TwitterURL: Codable, TwitterEntity {
|
||||
|
||||
let url: String?
|
||||
let indices: [Int]?
|
||||
let displayURL: String?
|
||||
let expandedURL: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case url = "url"
|
||||
case indices = "indices"
|
||||
case displayURL = "display_url"
|
||||
case expandedURL = "expanded_url"
|
||||
}
|
||||
|
||||
func renderAsHTML() -> String {
|
||||
var html = String()
|
||||
if let expandedURL = expandedURL, let displayURL = displayURL {
|
||||
html += "<a href=\"\(expandedURL)\">\(displayURL)</a>"
|
||||
}
|
||||
return html
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
//
|
||||
// TwitterUser.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 4/16/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct TwitterUser: Codable {
|
||||
|
||||
let name: String?
|
||||
let screenName: String?
|
||||
let avatarURL: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name = "name"
|
||||
case screenName = "screen_name"
|
||||
case avatarURL = "profile_image_url_https"
|
||||
}
|
||||
|
||||
var url: String {
|
||||
return "https://twitter.com/\(screenName ?? "")"
|
||||
}
|
||||
|
||||
func renderAsHTML() -> String? {
|
||||
var html = String()
|
||||
html += "<div><a href=\"\(url)\">"
|
||||
if let avatarURL = avatarURL {
|
||||
html += "<img class=\"twitterAvatar nnw-nozoom\" src=\"\(avatarURL)\">"
|
||||
}
|
||||
html += "<div class=\"twitterUsername\">"
|
||||
if let name = name {
|
||||
html += " \(name)"
|
||||
}
|
||||
html += "<br><span class=\"twitterScreenName\">"
|
||||
if let screenName = screenName {
|
||||
html += " @\(screenName)"
|
||||
}
|
||||
html += "</span></div></a></div>"
|
||||
return html
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
//
|
||||
// TwitterVideoInfo.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 4/18/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
|
||||
struct TwitterVideo: Codable {
|
||||
|
||||
let variants: [Variant]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case variants = "variants"
|
||||
}
|
||||
|
||||
struct Variant: Codable {
|
||||
|
||||
let bitrate: Int?
|
||||
let contentType: String?
|
||||
let url: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case bitrate = "bitrate"
|
||||
case contentType = "content_type"
|
||||
case url = "url"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -30,7 +30,7 @@ final class FeedlyGetCollectionsOperation: FeedlyOperation, FeedlyCollectionProv
|
||||
service.getCollections { result in
|
||||
switch result {
|
||||
case .success(let collections):
|
||||
self.logger.debug("Receving collections: \(collections.map({ $0.id }), privacy: .public)")
|
||||
self.logger.debug("Receiving collections: \(collections.map({ $0.id }), privacy: .public)")
|
||||
self.collections = collections
|
||||
self.didFinish()
|
||||
|
||||
|
||||
@@ -31,6 +31,10 @@ public struct SingleArticleFetcher: ArticleFetcher {
|
||||
public func fetchUnreadArticles() throws -> Set<Article> {
|
||||
return try account.fetchArticles(.articleIDs(Set([articleID])))
|
||||
}
|
||||
|
||||
public func fetchUnreadArticlesBetween(before: Date? = nil, after: Date? = nil) throws -> Set<Article> {
|
||||
return try account.fetchArticlesBetween(articleIDs: Set([articleID]), before: before, after: after)
|
||||
}
|
||||
|
||||
public func fetchUnreadArticlesAsync(_ completion: @escaping ArticleSetResultBlock) {
|
||||
return account.fetchArticlesAsync(.articleIDs(Set([articleID])), completion)
|
||||
|
||||
@@ -253,17 +253,13 @@ public final class WebFeed: Feed, Renamable, Hashable, ObservableObject {
|
||||
// MARK: - NotificationDisplayName
|
||||
public var notificationDisplayName: String {
|
||||
#if os(macOS)
|
||||
if self.url.contains("twitter.com") {
|
||||
return NSLocalizedString("Show notifications for new tweets", comment: "notifyNameDisplay / Twitter")
|
||||
} else if self.url.contains("www.reddit.com") {
|
||||
if self.url.contains("www.reddit.com") {
|
||||
return NSLocalizedString("Show notifications for new posts", comment: "notifyNameDisplay / Reddit")
|
||||
} else {
|
||||
return NSLocalizedString("Show notifications for new articles", comment: "notifyNameDisplay / Default")
|
||||
}
|
||||
#else
|
||||
if self.url.contains("twitter.com") {
|
||||
return NSLocalizedString("Notify about new tweets", comment: "notifyNameDisplay / Twitter")
|
||||
} else if self.url.contains("www.reddit.com") {
|
||||
if self.url.contains("www.reddit.com") {
|
||||
return NSLocalizedString("Notify about new posts", comment: "notifyNameDisplay / Reddit")
|
||||
} else {
|
||||
return NSLocalizedString("Notify about new articles", comment: "notifyNameDisplay / Default")
|
||||
|
||||
@@ -13,8 +13,6 @@ struct FeedlyTestSecrets: SecretsProvider {
|
||||
var mercuryClientSecret = ""
|
||||
var feedlyClientId = ""
|
||||
var feedlyClientSecret = ""
|
||||
var twitterConsumerKey = ""
|
||||
var twitterConsumerSecret = ""
|
||||
var redditConsumerKey = ""
|
||||
var inoreaderAppId = ""
|
||||
var inoreaderAppKey = ""
|
||||
|
||||
Reference in New Issue
Block a user