Merge branch 'release/0.1.0' into main

This commit is contained in:
CMK 2021-02-26 20:01:11 +08:00
commit bbe89fe313
297 changed files with 21100 additions and 116 deletions

4
.gitignore vendored
View File

@ -118,4 +118,6 @@ xcuserdata
**/xcshareddata/WorkspaceSettings.xcsettings
# End of https://www.toptal.com/developers/gitignore/api/swift,swiftpm,xcode,cocoapods
n
Localization/StringsConvertor/input
Localization/StringsConvertor/output

View File

@ -0,0 +1,151 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17709" systemVersion="20D74" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Application" representedClassName=".Application" syncable="YES">
<attribute name="identifier" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String"/>
<attribute name="vapidKey" optional="YES" attributeType="String"/>
<attribute name="website" optional="YES" attributeType="String"/>
<relationship name="toots" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="application" inverseEntity="Toot"/>
</entity>
<entity name="Attachment" representedClassName=".Attachment" syncable="YES">
<attribute name="blurhash" optional="YES" attributeType="String"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="descriptionString" optional="YES" attributeType="String"/>
<attribute name="domain" attributeType="String"/>
<attribute name="id" attributeType="String"/>
<attribute name="index" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="metaData" optional="YES" attributeType="Binary"/>
<attribute name="previewRemoteURL" optional="YES" attributeType="String"/>
<attribute name="previewURL" attributeType="String"/>
<attribute name="remoteURL" optional="YES" attributeType="String"/>
<attribute name="textURL" optional="YES" attributeType="String"/>
<attribute name="typeRaw" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="url" optional="YES" attributeType="String"/>
<relationship name="toot" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="mediaAttachments" inverseEntity="Toot"/>
</entity>
<entity name="Emoji" representedClassName=".Emoji" syncable="YES">
<attribute name="category" optional="YES" attributeType="String"/>
<attribute name="createAt" attributeType="Date" defaultDateTimeInterval="631123200" usesScalarValueType="NO"/>
<attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="shortcode" attributeType="String"/>
<attribute name="staticURL" attributeType="String"/>
<attribute name="url" attributeType="String"/>
<attribute name="visibleInPicker" attributeType="Boolean" usesScalarValueType="YES"/>
<relationship name="toot" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="emojis" inverseEntity="Toot"/>
</entity>
<entity name="History" representedClassName=".History" syncable="YES">
<attribute name="accounts" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="createAt" attributeType="Date" defaultDateTimeInterval="631123200" usesScalarValueType="NO"/>
<attribute name="day" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="identifier" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="uses" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="tag" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Tag" inverseName="histories" inverseEntity="Tag"/>
</entity>
<entity name="HomeTimelineIndex" representedClassName=".HomeTimelineIndex" syncable="YES">
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="deletedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String"/>
<attribute name="hasMore" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="identifier" attributeType="String"/>
<attribute name="userID" attributeType="String"/>
<relationship name="toot" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="homeTimelineIndexes" inverseEntity="Toot"/>
</entity>
<entity name="MastodonAuthentication" representedClassName=".MastodonAuthentication" syncable="YES">
<attribute name="activedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="appAccessToken" attributeType="String"/>
<attribute name="clientID" attributeType="String"/>
<attribute name="clientSecret" attributeType="String"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String"/>
<attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="userAccessToken" attributeType="String"/>
<attribute name="userID" attributeType="String"/>
<attribute name="username" attributeType="String"/>
<relationship name="user" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="mastodonAuthentication" inverseEntity="MastodonUser"/>
</entity>
<entity name="MastodonUser" representedClassName=".MastodonUser" syncable="YES">
<attribute name="acct" attributeType="String"/>
<attribute name="avatar" attributeType="String"/>
<attribute name="avatarStatic" optional="YES" attributeType="String"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="displayName" attributeType="String"/>
<attribute name="domain" attributeType="String"/>
<attribute name="id" attributeType="String"/>
<attribute name="identifier" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="username" attributeType="String"/>
<relationship name="bookmarked" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="bookmarkedBy" inverseEntity="Toot"/>
<relationship name="favourite" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="favouritedBy" inverseEntity="Toot"/>
<relationship name="mastodonAuthentication" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonAuthentication" inverseName="user" inverseEntity="MastodonAuthentication"/>
<relationship name="muted" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="mutedBy" inverseEntity="Toot"/>
<relationship name="pinnedToot" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="pinnedBy" inverseEntity="Toot"/>
<relationship name="reblogged" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="rebloggedBy" inverseEntity="Toot"/>
<relationship name="toots" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="author" inverseEntity="Toot"/>
</entity>
<entity name="Mention" representedClassName=".Mention" syncable="YES">
<attribute name="acct" attributeType="String"/>
<attribute name="createAt" attributeType="Date" defaultDateTimeInterval="631123200" usesScalarValueType="NO"/>
<attribute name="id" attributeType="String"/>
<attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="url" attributeType="String"/>
<attribute name="username" attributeType="String"/>
<relationship name="toot" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="mentions" inverseEntity="Toot"/>
</entity>
<entity name="Tag" representedClassName=".Tag" syncable="YES">
<attribute name="createAt" attributeType="Date" defaultDateTimeInterval="631123200" usesScalarValueType="NO"/>
<attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String"/>
<attribute name="url" attributeType="String"/>
<relationship name="histories" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="History" inverseName="tag" inverseEntity="History"/>
<relationship name="toot" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="tags" inverseEntity="Toot"/>
</entity>
<entity name="Toot" representedClassName=".Toot" syncable="YES">
<attribute name="content" attributeType="String"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="deletedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String"/>
<attribute name="favouritesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="id" attributeType="String"/>
<attribute name="identifier" attributeType="String"/>
<attribute name="inReplyToAccountID" optional="YES" attributeType="String"/>
<attribute name="inReplyToID" optional="YES" attributeType="String"/>
<attribute name="language" optional="YES" attributeType="String"/>
<attribute name="reblogsCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="repliesCount" optional="YES" attributeType="Integer 64" usesScalarValueType="NO"/>
<attribute name="sensitive" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="spoilerText" optional="YES" attributeType="String"/>
<attribute name="text" optional="YES" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="uri" attributeType="String"/>
<attribute name="url" attributeType="String"/>
<attribute name="visibility" optional="YES" attributeType="String"/>
<relationship name="application" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Application" inverseName="toots" inverseEntity="Application"/>
<relationship name="author" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="toots" inverseEntity="MastodonUser"/>
<relationship name="bookmarkedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="bookmarked" inverseEntity="MastodonUser"/>
<relationship name="emojis" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Emoji" inverseName="toot" inverseEntity="Emoji"/>
<relationship name="favouritedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="favourite" inverseEntity="MastodonUser"/>
<relationship name="homeTimelineIndexes" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="HomeTimelineIndex" inverseName="toot" inverseEntity="HomeTimelineIndex"/>
<relationship name="mediaAttachments" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Attachment" inverseName="toot" inverseEntity="Attachment"/>
<relationship name="mentions" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Mention" inverseName="toot" inverseEntity="Mention"/>
<relationship name="mutedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="muted" inverseEntity="MastodonUser"/>
<relationship name="pinnedBy" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="pinnedToot" inverseEntity="MastodonUser"/>
<relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="reblogFrom" inverseEntity="Toot"/>
<relationship name="reblogFrom" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="reblog" inverseEntity="Toot"/>
<relationship name="rebloggedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="reblogged" inverseEntity="MastodonUser"/>
<relationship name="tags" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Tag" inverseName="toot" inverseEntity="Tag"/>
</entity>
<elements>
<element name="Application" positionX="160" positionY="192" width="128" height="104"/>
<element name="Emoji" positionX="45" positionY="135" width="128" height="149"/>
<element name="History" positionX="27" positionY="126" width="128" height="119"/>
<element name="HomeTimelineIndex" positionX="0" positionY="0" width="128" height="134"/>
<element name="MastodonAuthentication" positionX="18" positionY="162" width="128" height="209"/>
<element name="MastodonUser" positionX="0" positionY="0" width="128" height="284"/>
<element name="Mention" positionX="9" positionY="108" width="128" height="134"/>
<element name="Tag" positionX="18" positionY="117" width="128" height="119"/>
<element name="Toot" positionX="0" positionY="0" width="128" height="524"/>
<element name="Attachment" positionX="72" positionY="162" width="128" height="14"/>
</elements>
</model>

View File

@ -0,0 +1,18 @@
//
// CoreDataStack.h
// CoreDataStack
//
// Created by MainasuK Cirno on 2021/1/27.
//
#import <Foundation/Foundation.h>
//! Project version number for CoreDataStack.
FOUNDATION_EXPORT double CoreDataStackVersionNumber;
//! Project version string for CoreDataStack.
FOUNDATION_EXPORT const unsigned char CoreDataStackVersionString[];
// In this header, you should import all the public headers of your framework using statements like #import <CoreDataStack/PublicHeader.h>

View File

@ -0,0 +1,101 @@
//
// CoreDataStack.swift
// CoreDataStack
//
// Created by Cirno MainasuK on 2021-1-27.
//
import os
import Foundation
import CoreData
public final class CoreDataStack {
private(set) var storeDescriptions: [NSPersistentStoreDescription]
init(persistentStoreDescriptions storeDescriptions: [NSPersistentStoreDescription]) {
self.storeDescriptions = storeDescriptions
}
public convenience init(databaseName: String = "shared") {
let storeURL = URL.storeURL(for: "group.org.joinmastodon.mastodon-temp", databaseName: databaseName)
let storeDescription = NSPersistentStoreDescription(url: storeURL)
self.init(persistentStoreDescriptions: [storeDescription])
}
public private(set) lazy var persistentContainer: NSPersistentContainer = {
/*
The persistent container for the application. This implementation
creates and returns a container, having loaded the store for the
application to it. This property is optional since there are legitimate
error conditions that could cause the creation of the store to fail.
*/
let container = CoreDataStack.persistentContainer()
CoreDataStack.configure(persistentContainer: container, storeDescriptions: storeDescriptions)
CoreDataStack.load(persistentContainer: container)
return container
}()
static func persistentContainer() -> NSPersistentContainer {
let bundles = [Bundle(for: Toot.self)]
guard let managedObjectModel = NSManagedObjectModel.mergedModel(from: bundles) else {
fatalError("cannot locate bundles")
}
let container = NSPersistentContainer(name: "CoreDataStack", managedObjectModel: managedObjectModel)
return container
}
static func configure(persistentContainer container: NSPersistentContainer, storeDescriptions: [NSPersistentStoreDescription]) {
container.persistentStoreDescriptions = storeDescriptions
}
static func load(persistentContainer container: NSPersistentContainer) {
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
/*
Typical reasons for an error here include:
* The parent directory does not exist, cannot be created, or disallows writing.
* The persistent store is not accessible, due to permissions or data protection when the device is locked.
* The device is out of space.
* The store could not be migrated to the current model version.
Check the error message to determine what the actual problem was.
*/
if let reason = error.userInfo["reason"] as? String,
(reason == "Can't find mapping model for migration" || reason == "Persistent store migration failed, missing mapping model.") {
if let storeDescription = container.persistentStoreDescriptions.first, let url = storeDescription.url {
try? container.persistentStoreCoordinator.destroyPersistentStore(at: url, ofType: NSSQLiteStoreType, options: nil)
os_log("%{public}s[%{public}ld], %{public}s: cannot migrate model. rebuild database…", ((#file as NSString).lastPathComponent), #line, #function)
} else {
assertionFailure()
}
}
fatalError("Unresolved error \(error), \(error.userInfo)")
}
container.viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
// it's looks like the remote notification only trigger when app enter and leave background
container.viewContext.automaticallyMergesChangesFromParent = true
os_log("%{public}s[%{public}ld], %{public}s: %s", ((#file as NSString).lastPathComponent), #line, #function, storeDescription.debugDescription)
})
}
}
extension CoreDataStack {
public func rebuild() {
let oldStoreURL = persistentContainer.persistentStoreCoordinator.url(for: persistentContainer.persistentStoreCoordinator.persistentStores.first!)
try! persistentContainer.persistentStoreCoordinator.destroyPersistentStore(at: oldStoreURL, ofType: NSSQLiteStoreType, options: nil)
CoreDataStack.load(persistentContainer: persistentContainer)
}
}

View File

@ -0,0 +1,61 @@
//
// Application.swift
// CoreDataStack
//
// Created by sxiaojian on 2021/2/3.
//
import CoreData
import Foundation
public final class Application: NSManagedObject {
public typealias ID = UUID
@NSManaged public private(set) var identifier: ID
@NSManaged public private(set) var createAt: Date
@NSManaged public private(set) var name: String
@NSManaged public private(set) var website: String?
@NSManaged public private(set) var vapidKey: String?
// one-to-many relationship
@NSManaged public private(set) var toots: Set<Toot>
}
public extension Application {
override func awakeFromInsert() {
super.awakeFromInsert()
identifier = UUID()
}
@discardableResult
static func insert(
into context: NSManagedObjectContext,
property: Property
) -> Application {
let app: Application = context.insertObject()
app.name = property.name
app.website = property.website
app.vapidKey = property.vapidKey
return app
}
}
public extension Application {
struct Property {
public let name: String
public let website: String?
public let vapidKey: String?
public init(name: String, website: String?, vapidKey: String?) {
self.name = name
self.website = website
self.vapidKey = vapidKey
}
}
}
extension Application: Managed {
public static var defaultSortDescriptors: [NSSortDescriptor] {
return [NSSortDescriptor(keyPath: \Application.createAt, ascending: false)]
}
}

View File

@ -0,0 +1,126 @@
//
// Attachment.swift
// CoreDataStack
//
// Created by MainasuK Cirno on 2021-2-23.
//
import CoreData
import Foundation
public final class Attachment: NSManagedObject {
public typealias ID = String
@NSManaged public private(set) var id: ID
@NSManaged public private(set) var domain: String
@NSManaged public private(set) var typeRaw: String
@NSManaged public private(set) var url: String
@NSManaged public private(set) var previewURL: String
@NSManaged public private(set) var remoteURL: String?
@NSManaged public private(set) var metaData: Data?
@NSManaged public private(set) var textURL: String?
@NSManaged public private(set) var descriptionString: String?
@NSManaged public private(set) var blurhash: String?
@NSManaged public private(set) var createdAt: Date
@NSManaged public private(set) var updatedAt: Date
@NSManaged public private(set) var index: NSNumber
// many-to-one relastionship
@NSManaged public private(set) var toot: Toot?
}
public extension Attachment {
override func awakeFromInsert() {
super.awakeFromInsert()
createdAt = Date()
}
@discardableResult
static func insert(
into context: NSManagedObjectContext,
property: Property
) -> Attachment {
let attachment: Attachment = context.insertObject()
attachment.domain = property.domain
attachment.index = property.index
attachment.id = property.id
attachment.typeRaw = property.typeRaw
attachment.url = property.url
attachment.previewURL = property.previewURL
attachment.remoteURL = property.remoteURL
attachment.metaData = property.metaData
attachment.textURL = property.textURL
attachment.descriptionString = property.descriptionString
attachment.blurhash = property.blurhash
attachment.updatedAt = property.networkDate
return attachment
}
func didUpdate(at networkDate: Date) {
self.updatedAt = networkDate
}
}
public extension Attachment {
struct Property {
public let domain: String
public let index: NSNumber
public let id: ID
public let typeRaw: String
public let url: String
public let previewURL: String
public let remoteURL: String?
public let metaData: Data?
public let textURL: String?
public let descriptionString: String?
public let blurhash: String?
public let networkDate: Date
public init(
domain: String,
index: Int,
id: Attachment.ID,
typeRaw: String,
url: String,
previewURL: String,
remoteURL: String?,
metaData: Data?,
textURL: String?,
descriptionString: String?,
blurhash: String?,
networkDate: Date
) {
self.domain = domain
self.index = NSNumber(value: index)
self.id = id
self.typeRaw = typeRaw
self.url = url
self.previewURL = previewURL
self.remoteURL = remoteURL
self.metaData = metaData
self.textURL = textURL
self.descriptionString = descriptionString
self.blurhash = blurhash
self.networkDate = networkDate
}
}
}
extension Attachment: Managed {
public static var defaultSortDescriptors: [NSSortDescriptor] {
return [NSSortDescriptor(keyPath: \Attachment.createdAt, ascending: false)]
}
}

View File

@ -0,0 +1,70 @@
//
// Emoji.swift
// CoreDataStack
//
// Created by sxiaojian on 2021/2/1.
//
import CoreData
import Foundation
public final class Emoji: NSManagedObject {
public typealias ID = UUID
@NSManaged public private(set) var identifier: ID
@NSManaged public private(set) var createAt: Date
@NSManaged public private(set) var shortcode: String
@NSManaged public private(set) var url: String
@NSManaged public private(set) var staticURL: String
@NSManaged public private(set) var visibleInPicker: Bool
@NSManaged public private(set) var category: String?
// many-to-one relationship
@NSManaged public private(set) var toot: Toot?
}
public extension Emoji {
override func awakeFromInsert() {
super.awakeFromInsert()
identifier = UUID()
}
@discardableResult
static func insert(
into context: NSManagedObjectContext,
property: Property
) -> Emoji {
let emoji: Emoji = context.insertObject()
emoji.shortcode = property.shortcode
emoji.url = property.url
emoji.staticURL = property.staticURL
emoji.visibleInPicker = property.visibleInPicker
return emoji
}
}
public extension Emoji {
struct Property {
public let shortcode: String
public let url: String
public let staticURL: String
public let visibleInPicker: Bool
public let category: String?
public init(shortcode: String, url: String, staticURL: String, visibleInPicker: Bool, category: String?) {
self.shortcode = shortcode
self.url = url
self.staticURL = staticURL
self.visibleInPicker = visibleInPicker
self.category = category
}
}
}
extension Emoji: Managed {
public static var defaultSortDescriptors: [NSSortDescriptor] {
return [NSSortDescriptor(keyPath: \Emoji.createAt, ascending: false)]
}
}

View File

@ -0,0 +1,61 @@
//
// History.swift
// CoreDataStack
//
// Created by sxiaojian on 2021/2/1.
//
import CoreData
import Foundation
public final class History: NSManagedObject {
public typealias ID = UUID
@NSManaged public private(set) var identifier: ID
@NSManaged public private(set) var createAt: Date
@NSManaged public private(set) var day: Date
@NSManaged public private(set) var uses: Int
@NSManaged public private(set) var accounts: Int
// many-to-one relationship
@NSManaged public private(set) var tag: Tag
}
public extension History {
override func awakeFromInsert() {
super.awakeFromInsert()
identifier = UUID()
}
@discardableResult
static func insert(
into context: NSManagedObjectContext,
property: Property
) -> History {
let history: History = context.insertObject()
history.day = property.day
history.uses = property.uses
history.accounts = property.accounts
return history
}
}
public extension History {
struct Property {
public let day: Date
public let uses: Int
public let accounts: Int
public init(day: Date, uses: Int, accounts: Int) {
self.day = day
self.uses = uses
self.accounts = accounts
}
}
}
extension History: Managed {
public static var defaultSortDescriptors: [NSSortDescriptor] {
return [NSSortDescriptor(keyPath: \History.createAt, ascending: false)]
}
}

View File

@ -0,0 +1,92 @@
//
// HomeTimelineIndex.swift
// CoreDataStack
//
// Created by MainasuK Cirno on 2021/1/27.
//
import Foundation
import CoreData
final public class HomeTimelineIndex: NSManagedObject {
public typealias ID = String
@NSManaged public private(set) var identifier: ID
@NSManaged public private(set) var domain: String
@NSManaged public private(set) var userID: String
@NSManaged public private(set) var hasMore: Bool // default NO
@NSManaged public private(set) var createdAt: Date
@NSManaged public private(set) var deletedAt: Date?
// many-to-one relationship
@NSManaged public private(set) var toot: Toot
}
extension HomeTimelineIndex {
@discardableResult
public static func insert(
into context: NSManagedObjectContext,
property: Property,
toot: Toot
) -> HomeTimelineIndex {
let index: HomeTimelineIndex = context.insertObject()
index.identifier = property.identifier
index.domain = property.domain
index.userID = property.userID
index.createdAt = toot.createdAt
index.toot = toot
return index
}
public func update(hasMore: Bool) {
if self.hasMore != hasMore {
self.hasMore = hasMore
}
}
// internal method for Toot call
func softDelete() {
deletedAt = Date()
}
}
extension HomeTimelineIndex {
public struct Property {
public let identifier: String
public let domain: String
public let userID: String
public init(domain: String,userID: String) {
self.identifier = UUID().uuidString + "@" + domain
self.domain = domain
self.userID = userID
}
}
}
extension HomeTimelineIndex: Managed {
public static var defaultSortDescriptors: [NSSortDescriptor] {
return [NSSortDescriptor(keyPath: \HomeTimelineIndex.createdAt, ascending: false)]
}
}
extension HomeTimelineIndex {
public static func predicate(userID: String) -> NSPredicate {
return NSPredicate(format: "%K == %@", #keyPath(HomeTimelineIndex.userID), userID)
}
public static func notDeleted() -> NSPredicate {
return NSPredicate(format: "%K == nil", #keyPath(HomeTimelineIndex.deletedAt))
}
}

View File

@ -0,0 +1,161 @@
//
// MastodonAuthentication.swift
// CoreDataStack
//
// Created by MainasuK Cirno on 2021/2/3.
//
import Foundation
import CoreData
final public class MastodonAuthentication: NSManagedObject {
public typealias ID = UUID
@NSManaged public private(set) var identifier: ID
@NSManaged public private(set) var domain: String
@NSManaged public private(set) var userID: String
@NSManaged public private(set) var username: String
@NSManaged public private(set) var appAccessToken: String
@NSManaged public private(set) var userAccessToken: String
@NSManaged public private(set) var clientID: String
@NSManaged public private(set) var clientSecret: String
@NSManaged public private(set) var createdAt: Date
@NSManaged public private(set) var updatedAt: Date
@NSManaged public private(set) var activedAt: Date
// one-to-one relationship
@NSManaged public private(set) var user: MastodonUser
}
extension MastodonAuthentication {
public override func awakeFromInsert() {
super.awakeFromInsert()
identifier = UUID()
let now = Date()
createdAt = now
updatedAt = now
activedAt = now
}
@discardableResult
public static func insert(
into context: NSManagedObjectContext,
property: Property,
user: MastodonUser
) -> MastodonAuthentication {
let authentication: MastodonAuthentication = context.insertObject()
authentication.domain = property.domain
authentication.userID = property.userID
authentication.username = property.username
authentication.appAccessToken = property.appAccessToken
authentication.userAccessToken = property.userAccessToken
authentication.clientID = property.clientID
authentication.clientSecret = property.clientSecret
authentication.user = user
return authentication
}
public func update(username: String) {
if self.username != username {
self.username = username
}
}
public func update(appAccessToken: String) {
if self.appAccessToken != appAccessToken {
self.appAccessToken = appAccessToken
}
}
public func update(userAccessToken: String) {
if self.userAccessToken != userAccessToken {
self.userAccessToken = userAccessToken
}
}
public func update(clientID: String) {
if self.clientID != clientID {
self.clientID = clientID
}
}
public func update(clientSecret: String) {
if self.clientSecret != clientSecret {
self.clientSecret = clientSecret
}
}
public func update(activedAt: Date) {
if self.activedAt != activedAt {
self.activedAt = activedAt
}
}
public func didUpdate(at networkDate: Date) {
self.updatedAt = networkDate
}
}
extension MastodonAuthentication {
public struct Property {
public let domain: String
public let userID: String
public let username: String
public let appAccessToken: String
public let userAccessToken: String
public let clientID: String
public let clientSecret: String
public init(
domain: String,
userID: String,
username: String,
appAccessToken: String,
userAccessToken: String,
clientID: String,
clientSecret: String
) {
self.domain = domain
self.userID = userID
self.username = username
self.appAccessToken = appAccessToken
self.userAccessToken = userAccessToken
self.clientID = clientID
self.clientSecret = clientSecret
}
}
}
extension MastodonAuthentication: Managed {
public static var defaultSortDescriptors: [NSSortDescriptor] {
return [NSSortDescriptor(keyPath: \MastodonAuthentication.createdAt, ascending: false)]
}
}
extension MastodonAuthentication {
static func predicate(domain: String) -> NSPredicate {
return NSPredicate(format: "%K == %@", #keyPath(MastodonAuthentication.domain), domain)
}
static func predicate(userID: String) -> NSPredicate {
return NSPredicate(format: "%K == %@", #keyPath(MastodonAuthentication.userID), userID)
}
public static func predicate(domain: String, userID: String) -> NSPredicate {
return NSCompoundPredicate(andPredicateWithSubpredicates: [
MastodonAuthentication.predicate(domain: domain),
MastodonAuthentication.predicate(userID: userID)
])
}
}

View File

@ -0,0 +1,186 @@
//
// MastodonUser.swift
// CoreDataStack
//
// Created by MainasuK Cirno on 2021/1/27.
//
import CoreData
import Foundation
final public class MastodonUser: NSManagedObject {
public typealias ID = String
@NSManaged public private(set) var identifier: ID
@NSManaged public private(set) var domain: String
@NSManaged public private(set) var id: ID
@NSManaged public private(set) var acct: String
@NSManaged public private(set) var username: String
@NSManaged public private(set) var displayName: String
@NSManaged public private(set) var avatar: String
@NSManaged public private(set) var avatarStatic: String?
@NSManaged public private(set) var createdAt: Date
@NSManaged public private(set) var updatedAt: Date
// one-to-one relationship
@NSManaged public private(set) var pinnedToot: Toot?
@NSManaged public private(set) var mastodonAuthentication: MastodonAuthentication?
// one-to-many relationship
@NSManaged public private(set) var toots: Set<Toot>?
// many-to-many relationship
@NSManaged public private(set) var favourite: Set<Toot>?
@NSManaged public private(set) var reblogged: Set<Toot>?
@NSManaged public private(set) var muted: Set<Toot>?
@NSManaged public private(set) var bookmarked: Set<Toot>?
}
extension MastodonUser {
@discardableResult
public static func insert(
into context: NSManagedObjectContext,
property: Property
) -> MastodonUser {
let user: MastodonUser = context.insertObject()
user.identifier = property.identifier
user.domain = property.domain
user.id = property.id
user.acct = property.acct
user.username = property.username
user.displayName = property.displayName
user.avatar = property.avatar
user.avatarStatic = property.avatarStatic
user.createdAt = property.createdAt
user.updatedAt = property.networkDate
return user
}
public func update(acct: String) {
if self.acct != acct {
self.acct = acct
}
}
public func update(username: String) {
if self.username != username {
self.username = username
}
}
public func update(displayName: String) {
if self.displayName != displayName {
self.displayName = displayName
}
}
public func update(avatar: String) {
if self.avatar != avatar {
self.avatar = avatar
}
}
public func update(avatarStatic: String?) {
if self.avatarStatic != avatarStatic {
self.avatarStatic = avatarStatic
}
}
public func didUpdate(at networkDate: Date) {
self.updatedAt = networkDate
}
}
public extension MastodonUser {
struct Property {
public let identifier: String
public let domain: String
public let id: String
public let acct: String
public let username: String
public let displayName: String
public let avatar: String
public let avatarStatic: String?
public let createdAt: Date
public let networkDate: Date
public init(
id: String,
domain: String,
acct: String,
username: String,
displayName: String,
avatar: String,
avatarStatic: String?,
createdAt: Date,
networkDate: Date
) {
self.identifier = id + "@" + domain
self.domain = domain
self.id = id
self.acct = acct
self.username = username
self.displayName = displayName
self.avatar = avatar
self.avatarStatic = avatarStatic
self.createdAt = createdAt
self.networkDate = networkDate
}
}
}
extension MastodonUser: Managed {
public static var defaultSortDescriptors: [NSSortDescriptor] {
return [NSSortDescriptor(keyPath: \MastodonUser.createdAt, ascending: false)]
}
}
extension MastodonUser {
static func predicate(domain: String) -> NSPredicate {
return NSPredicate(format: "%K == %@", #keyPath(MastodonUser.domain), domain)
}
static func predicate(id: String) -> NSPredicate {
return NSPredicate(format: "%K == %@", #keyPath(MastodonUser.id), id)
}
public static func predicate(domain: String, id: String) -> NSPredicate {
return NSCompoundPredicate(andPredicateWithSubpredicates: [
MastodonUser.predicate(domain: domain),
MastodonUser.predicate(id: id)
])
}
static func predicate(ids: [String]) -> NSPredicate {
return NSPredicate(format: "%K IN %@", #keyPath(MastodonUser.id), ids)
}
public static func predicate(domain: String, ids: [String]) -> NSPredicate {
return NSCompoundPredicate(andPredicateWithSubpredicates: [
MastodonUser.predicate(domain: domain),
MastodonUser.predicate(ids: ids)
])
}
static func predicate(username: String) -> NSPredicate {
return NSPredicate(format: "%K == %@", #keyPath(MastodonUser.username), username)
}
public static func predicate(domain: String, username: String) -> NSPredicate {
return NSCompoundPredicate(andPredicateWithSubpredicates: [
MastodonUser.predicate(domain: domain),
MastodonUser.predicate(username: username)
])
}
}

View File

@ -0,0 +1,65 @@
//
// Mention.swift
// CoreDataStack
//
// Created by sxiaojian on 2021/2/1.
//
import CoreData
import Foundation
public final class Mention: NSManagedObject {
public typealias ID = UUID
@NSManaged public private(set) var identifier: ID
@NSManaged public private(set) var id: String
@NSManaged public private(set) var createAt: Date
@NSManaged public private(set) var username: String
@NSManaged public private(set) var acct: String
@NSManaged public private(set) var url: String
// many-to-one relationship
@NSManaged public private(set) var toot: Toot
}
public extension Mention {
override func awakeFromInsert() {
super.awakeFromInsert()
identifier = UUID()
}
@discardableResult
static func insert(
into context: NSManagedObjectContext,
property: Property
) -> Mention {
let mention: Mention = context.insertObject()
mention.id = property.id
mention.username = property.username
mention.acct = property.acct
mention.url = property.url
return mention
}
}
public extension Mention {
struct Property {
public let id: String
public let username: String
public let acct: String
public let url: String
public init(id: String, username: String, acct: String, url: String) {
self.id = id
self.username = username
self.acct = acct
self.url = url
}
}
}
extension Mention: Managed {
public static var defaultSortDescriptors: [NSSortDescriptor] {
return [NSSortDescriptor(keyPath: \Mention.createAt, ascending: false)]
}
}

View File

@ -0,0 +1,64 @@
//
// Tag.swift
// CoreDataStack
//
// Created by sxiaojian on 2021/2/1.
//
import CoreData
import Foundation
public final class Tag: NSManagedObject {
public typealias ID = UUID
@NSManaged public private(set) var identifier: ID
@NSManaged public private(set) var createAt: Date
@NSManaged public private(set) var name: String
@NSManaged public private(set) var url: String
// many-to-many relationship
@NSManaged public private(set) var toot: Toot
// one-to-many relationship
@NSManaged public private(set) var histories: Set<History>?
}
public extension Tag {
override func awakeFromInsert() {
super.awakeFromInsert()
identifier = UUID()
}
@discardableResult
static func insert(
into context: NSManagedObjectContext,
property: Property
) -> Tag {
let tag: Tag = context.insertObject()
tag.name = property.name
tag.url = property.url
if let histories = property.histories {
tag.mutableSetValue(forKey: #keyPath(Tag.histories)).addObjects(from: histories)
}
return tag
}
}
public extension Tag {
struct Property {
public let name: String
public let url: String
public let histories: [History]?
public init(name: String, url: String, histories: [History]?) {
self.name = name
self.url = url
self.histories = histories
}
}
}
extension Tag: Managed {
public static var defaultSortDescriptors: [NSSortDescriptor] {
return [NSSortDescriptor(keyPath: \Tag.createAt, ascending: false)]
}
}

View File

@ -0,0 +1,323 @@
//
// Toot.swift
// CoreDataStack
//
// Created by MainasuK Cirno on 2021/1/27.
//
import CoreData
import Foundation
public final class Toot: NSManagedObject {
public typealias ID = String
@NSManaged public private(set) var identifier: ID
@NSManaged public private(set) var domain: String
@NSManaged public private(set) var id: String
@NSManaged public private(set) var uri: String
@NSManaged public private(set) var createdAt: Date
@NSManaged public private(set) var content: String
@NSManaged public private(set) var visibility: String?
@NSManaged public private(set) var sensitive: Bool
@NSManaged public private(set) var spoilerText: String?
@NSManaged public private(set) var application: Application?
// Informational
@NSManaged public private(set) var reblogsCount: NSNumber
@NSManaged public private(set) var favouritesCount: NSNumber
@NSManaged public private(set) var repliesCount: NSNumber?
@NSManaged public private(set) var url: String?
@NSManaged public private(set) var inReplyToID: Toot.ID?
@NSManaged public private(set) var inReplyToAccountID: MastodonUser.ID?
@NSManaged public private(set) var language: String? // (ISO 639 Part 1 two-letter language code)
@NSManaged public private(set) var text: String?
// many-to-one relastionship
@NSManaged public private(set) var author: MastodonUser
@NSManaged public private(set) var reblog: Toot?
// many-to-many relastionship
@NSManaged public private(set) var favouritedBy: Set<MastodonUser>?
@NSManaged public private(set) var rebloggedBy: Set<MastodonUser>?
@NSManaged public private(set) var mutedBy: Set<MastodonUser>?
@NSManaged public private(set) var bookmarkedBy: Set<MastodonUser>?
// one-to-one relastionship
@NSManaged public private(set) var pinnedBy: MastodonUser?
// one-to-many relationship
@NSManaged public private(set) var reblogFrom: Set<Toot>?
@NSManaged public private(set) var mentions: Set<Mention>?
@NSManaged public private(set) var emojis: Set<Emoji>?
@NSManaged public private(set) var tags: Set<Tag>?
@NSManaged public private(set) var homeTimelineIndexes: Set<HomeTimelineIndex>?
@NSManaged public private(set) var mediaAttachments: Set<Attachment>?
@NSManaged public private(set) var updatedAt: Date
@NSManaged public private(set) var deletedAt: Date?
}
public extension Toot {
@discardableResult
static func insert(
into context: NSManagedObjectContext,
property: Property,
author: MastodonUser,
reblog: Toot?,
application: Application?,
mentions: [Mention]?,
emojis: [Emoji]?,
tags: [Tag]?,
mediaAttachments: [Attachment]?,
favouritedBy: MastodonUser?,
rebloggedBy: MastodonUser?,
mutedBy: MastodonUser?,
bookmarkedBy: MastodonUser?,
pinnedBy: MastodonUser?
) -> Toot {
let toot: Toot = context.insertObject()
toot.identifier = property.identifier
toot.domain = property.domain
toot.id = property.id
toot.uri = property.uri
toot.createdAt = property.createdAt
toot.content = property.content
toot.visibility = property.visibility
toot.sensitive = property.sensitive
toot.spoilerText = property.spoilerText
toot.application = application
toot.reblogsCount = property.reblogsCount
toot.favouritesCount = property.favouritesCount
toot.repliesCount = property.repliesCount
toot.url = property.url
toot.inReplyToID = property.inReplyToID
toot.inReplyToAccountID = property.inReplyToAccountID
toot.language = property.language
toot.text = property.text
toot.author = author
toot.reblog = reblog
toot.pinnedBy = pinnedBy
if let mentions = mentions {
toot.mutableSetValue(forKey: #keyPath(Toot.mentions)).addObjects(from: mentions)
}
if let emojis = emojis {
toot.mutableSetValue(forKey: #keyPath(Toot.emojis)).addObjects(from: emojis)
}
if let tags = tags {
toot.mutableSetValue(forKey: #keyPath(Toot.tags)).addObjects(from: tags)
}
if let mediaAttachments = mediaAttachments {
toot.mutableSetValue(forKey: #keyPath(Toot.mediaAttachments)).addObjects(from: mediaAttachments)
}
if let favouritedBy = favouritedBy {
toot.mutableSetValue(forKey: #keyPath(Toot.favouritedBy)).add(favouritedBy)
}
if let rebloggedBy = rebloggedBy {
toot.mutableSetValue(forKey: #keyPath(Toot.rebloggedBy)).add(rebloggedBy)
}
if let mutedBy = mutedBy {
toot.mutableSetValue(forKey: #keyPath(Toot.mutedBy)).add(mutedBy)
}
if let bookmarkedBy = bookmarkedBy {
toot.mutableSetValue(forKey: #keyPath(Toot.bookmarkedBy)).add(bookmarkedBy)
}
toot.updatedAt = property.networkDate
return toot
}
func update(reblogsCount: NSNumber) {
if self.reblogsCount.intValue != reblogsCount.intValue {
self.reblogsCount = reblogsCount
}
}
func update(favouritesCount: NSNumber) {
if self.favouritesCount.intValue != favouritesCount.intValue {
self.favouritesCount = favouritesCount
}
}
func update(repliesCount: NSNumber?) {
guard let count = repliesCount else {
return
}
if self.repliesCount?.intValue != count.intValue {
self.repliesCount = repliesCount
}
}
func update(liked: Bool, mastodonUser: MastodonUser) {
if liked {
if !(self.favouritedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(Toot.favouritedBy)).addObjects(from: [mastodonUser])
}
} else {
if (self.favouritedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(Toot.favouritedBy)).remove(mastodonUser)
}
}
}
func update(reblogged: Bool, mastodonUser: MastodonUser) {
if reblogged {
if !(self.rebloggedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(Toot.rebloggedBy)).addObjects(from: [mastodonUser])
}
} else {
if (self.rebloggedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(Toot.rebloggedBy)).remove(mastodonUser)
}
}
}
func update(muted: Bool, mastodonUser: MastodonUser) {
if muted {
if !(self.mutedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(Toot.mutedBy)).addObjects(from: [mastodonUser])
}
} else {
if (self.mutedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(Toot.mutedBy)).remove(mastodonUser)
}
}
}
func update(bookmarked: Bool, mastodonUser: MastodonUser) {
if bookmarked {
if !(self.bookmarkedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(Toot.bookmarkedBy)).addObjects(from: [mastodonUser])
}
} else {
if (self.bookmarkedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(Toot.bookmarkedBy)).remove(mastodonUser)
}
}
}
func didUpdate(at networkDate: Date) {
self.updatedAt = networkDate
}
}
public extension Toot {
struct Property {
public let identifier: ID
public let domain: String
public let id: String
public let uri: String
public let createdAt: Date
public let content: String
public let visibility: String?
public let sensitive: Bool
public let spoilerText: String?
public let reblogsCount: NSNumber
public let favouritesCount: NSNumber
public let repliesCount: NSNumber?
public let url: String?
public let inReplyToID: Toot.ID?
public let inReplyToAccountID: MastodonUser.ID?
public let language: String? // (ISO 639 Part @1 two-letter language code)
public let text: String?
public let networkDate: Date
public init(
domain: String,
id: String,
uri: String,
createdAt: Date,
content: String,
visibility: String?,
sensitive: Bool,
spoilerText: String?,
reblogsCount: NSNumber,
favouritesCount: NSNumber,
repliesCount: NSNumber?,
url: String?,
inReplyToID: Toot.ID?,
inReplyToAccountID: MastodonUser.ID?,
language: String?,
text: String?,
networkDate: Date
) {
self.identifier = id + "@" + domain
self.domain = domain
self.id = id
self.uri = uri
self.createdAt = createdAt
self.content = content
self.visibility = visibility
self.sensitive = sensitive
self.spoilerText = spoilerText
self.reblogsCount = reblogsCount
self.favouritesCount = favouritesCount
self.repliesCount = repliesCount
self.url = url
self.inReplyToID = inReplyToID
self.inReplyToAccountID = inReplyToAccountID
self.language = language
self.text = text
self.networkDate = networkDate
}
}
}
extension Toot: Managed {
public static var defaultSortDescriptors: [NSSortDescriptor] {
return [NSSortDescriptor(keyPath: \Toot.createdAt, ascending: false)]
}
}
extension Toot {
static func predicate(domain: String) -> NSPredicate {
return NSPredicate(format: "%K == %@", #keyPath(Toot.domain), domain)
}
static func predicate(id: String) -> NSPredicate {
return NSPredicate(format: "%K == %@", #keyPath(Toot.id), id)
}
public static func predicate(domain: String, id: String) -> NSPredicate {
return NSCompoundPredicate(andPredicateWithSubpredicates: [
predicate(domain: domain),
predicate(id: id)
])
}
static func predicate(ids: [String]) -> NSPredicate {
return NSPredicate(format: "%K IN %@", #keyPath(Toot.id), ids)
}
public static func predicate(domain: String, ids: [String]) -> NSPredicate {
return NSCompoundPredicate(andPredicateWithSubpredicates: [
predicate(domain: domain),
predicate(ids: ids)
])
}
public static func notDeleted() -> NSPredicate {
return NSPredicate(format: "%K == nil", #keyPath(Toot.deletedAt))
}
public static func deleted() -> NSPredicate {
return NSPredicate(format: "%K != nil", #keyPath(Toot.deletedAt))
}
}

View File

@ -0,0 +1,29 @@
//
// Collection.swift
// CoreDataStack
//
// Created by Cirno MainasuK on 2020-10-14.
//
import Foundation
import CoreData
extension Collection where Iterator.Element: NSManagedObject {
public func fetchFaults() {
guard !self.isEmpty else { return }
guard let context = self.first?.managedObjectContext else {
fatalError("Managed object must have context")
}
let faults = self.filter { $0.isFault }
guard let object = faults.first else { return }
let request = NSFetchRequest<Iterator.Element>()
request.entity = object.entity
request.returnsObjectsAsFaults = false
request.predicate = NSPredicate(format: "self in %@", faults)
do {
let _ = try context.fetch(request)
} catch {
assertionFailure(error.localizedDescription)
}
}
}

View File

@ -0,0 +1,49 @@
//
// NSManagedObjectContext.swift
// CoreDataStack
//
// Created by Cirno MainasuK on 2020-8-10.
//
import os
import Foundation
import Combine
import CoreData
extension NSManagedObjectContext {
public func insert<T: NSManagedObject>() -> T where T: Managed {
guard let object = NSEntityDescription.insertNewObject(forEntityName: T.entityName, into: self) as? T else {
fatalError("cannot insert object: \(T.self)")
}
return object
}
public func saveOrRollback() throws {
do {
guard hasChanges else {
return
}
try save()
} catch {
rollback()
os_log("%{public}s[%{public}ld], %{public}s: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
throw error
}
}
public func performChanges(block: @escaping () -> Void) -> Future<Result<Void, Error>, Never> {
Future { promise in
self.perform {
block()
do {
try self.saveOrRollback()
promise(.success(Result.success(())))
} catch {
promise(.success(Result.failure(error)))
}
}
}
}
}

View File

@ -0,0 +1,35 @@
//
// UIFont.swift
// CoreDataStack
//
// Created by sxiaojian on 2021/1/28.
//
import UIKit
extension UIFont {
// refs: https://stackoverflow.com/questions/26371024/limit-supported-dynamic-type-font-sizes
static func preferredFont(withTextStyle textStyle: UIFont.TextStyle, maxSize: CGFloat) -> UIFont {
// Get the descriptor
let fontDescriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: textStyle)
// Return a font with the minimum size
return UIFont(descriptor: fontDescriptor, size: min(fontDescriptor.pointSize, maxSize))
}
public static func preferredMonospacedFont(withTextStyle textStyle: UIFont.TextStyle, compatibleWith traitCollection: UITraitCollection? = nil) -> UIFont {
let fontDescription = UIFontDescriptor.preferredFontDescriptor(withTextStyle: textStyle).addingAttributes([
UIFontDescriptor.AttributeName.featureSettings: [
[
UIFontDescriptor.FeatureKey.featureIdentifier:
kNumberSpacingType,
UIFontDescriptor.FeatureKey.typeIdentifier:
kMonospacedNumbersSelector
]
]
])
return UIFontMetrics(forTextStyle: textStyle).scaledFont(for: UIFont(descriptor: fontDescription, size: 0), compatibleWith: traitCollection)
}
}

View File

@ -0,0 +1,23 @@
//
// URL.swift
// CoreDataStack
//
// Created by Cirno MainasuK on 2021-1-27.
//
import Foundation
public extension URL {
/// Returns a URL for the given app group and database pointing to the sqlite database.
static func storeURL(for appGroup: String, databaseName: String) -> URL {
guard let fileContainer = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else {
fatalError("Shared file container could not be created.")
}
return fileContainer
.appendingPathComponent("Databases", isDirectory: true)
.appendingPathComponent("\(databaseName).sqlite")
}
}

22
CoreDataStack/Info.plist Normal file
View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
</dict>
</plist>

View File

@ -0,0 +1,81 @@
//
// Managed.swift
// CoreDataStack
//
// Created by Cirno MainasuK on 2020-8-6.
//
import Foundation
import CoreData
public protocol Managed: class, NSFetchRequestResult {
static var entityName: String { get }
static var defaultSortDescriptors: [NSSortDescriptor] { get }
}
extension Managed {
public static var defaultSortDescriptors: [NSSortDescriptor] {
return []
}
public static var sortedFetchRequest: NSFetchRequest<Self> {
let request = NSFetchRequest<Self>(entityName: entityName)
request.sortDescriptors = defaultSortDescriptors
return request
}
}
extension NSManagedObjectContext {
public func insertObject<T: NSManagedObject>() -> T where T: Managed {
guard let object = NSEntityDescription.insertNewObject(forEntityName: T.entityName, into: self) as? T else {
fatalError("Wrong object type")
}
return object
}
}
extension Managed where Self: NSManagedObject {
public static var entityName: String { return entity().name! }
}
extension Managed where Self: NSManagedObject {
public static func findOrCreate(in context: NSManagedObjectContext, matching predicate: NSPredicate, configure: (Self) -> Void) -> Self {
guard let object = findOrFetch(in: context, matching: predicate) else {
let newObject: Self = context.insertObject()
configure(newObject)
return newObject
}
return object
}
public static func findOrFetch(in context: NSManagedObjectContext, matching predicate: NSPredicate) -> Self? {
guard let object = materializedObject(in: context, matching: predicate) else {
return fetch(in: context) { request in
request.predicate = predicate
request.returnsObjectsAsFaults = false
request.fetchLimit = 1
}.first
}
return object
}
public static func materializedObject(in context: NSManagedObjectContext, matching predicate: NSPredicate) -> Self? {
for object in context.registeredObjects where !object.isFault {
guard let result = object as? Self, predicate.evaluate(with: result) else { continue }
return result
}
return nil
}
public static func fetch(in context: NSManagedObjectContext, configurationBlock: (NSFetchRequest<Self>) -> Void = { _ in }) -> [Self] {
let request = NSFetchRequest<Self>(entityName: Self.entityName)
configurationBlock(request)
return try! context.fetch(request)
}
}

View File

@ -0,0 +1,12 @@
//
// NetworkUpdatable.swift
// CoreDataStack
//
// Created by Cirno MainasuK on 2020-9-4.
//
import Foundation
public protocol NetworkUpdatable {
var networkDate: Date { get }
}

View File

@ -0,0 +1,62 @@
//
// ManagedObjectContextObjectsDidChange.swift
// CoreDataStack
//
// Created by sxiaojian on 2021/2/8.
//
import Foundation
import CoreData
public struct ManagedObjectContextObjectsDidChangeNotification {
public let notification: Notification
public let managedObjectContext: NSManagedObjectContext
public init?(notification: Notification) {
guard notification.name == .NSManagedObjectContextObjectsDidChange,
let managedObjectContext = notification.object as? NSManagedObjectContext else {
return nil
}
self.notification = notification
self.managedObjectContext = managedObjectContext
}
}
extension ManagedObjectContextObjectsDidChangeNotification {
public var insertedObjects: Set<NSManagedObject> {
return objects(forKey: NSInsertedObjectsKey)
}
public var updatedObjects: Set<NSManagedObject> {
return objects(forKey: NSUpdatedObjectsKey)
}
public var deletedObjects: Set<NSManagedObject> {
return objects(forKey: NSDeletedObjectsKey)
}
public var refreshedObjects: Set<NSManagedObject> {
return objects(forKey: NSRefreshedObjectsKey)
}
public var invalidedObjects: Set<NSManagedObject> {
return objects(forKey: NSInvalidatedObjectsKey)
}
public var invalidatedAllObjects: Bool {
return notification.userInfo?[NSInvalidatedAllObjectsKey] != nil
}
}
extension ManagedObjectContextObjectsDidChangeNotification {
private func objects(forKey key: String) -> Set<NSManagedObject> {
return notification.userInfo?[key] as? Set<NSManagedObject> ?? Set()
}
}

View File

@ -0,0 +1,80 @@
//
// ManagedObjectObserver.swift
// CoreDataStack
//
// Created by sxiaojian on 2021/2/8.
//
import Foundation
import CoreData
import Combine
final public class ManagedObjectObserver {
private init() { }
}
extension ManagedObjectObserver {
public static func observe(object: NSManagedObject) -> AnyPublisher<Change, Error> {
guard let context = object.managedObjectContext else {
return Fail(error: .noManagedObjectContext).eraseToAnyPublisher()
}
return NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: context)
.tryMap { notification in
guard let notification = ManagedObjectContextObjectsDidChangeNotification(notification: notification) else {
throw Error.notManagedObjectChangeNotification
}
let changeType = ManagedObjectObserver.changeType(of: object, in: notification)
return Change(
changeType: changeType,
changeNotification: notification
)
}
.mapError { error -> Error in
return (error as? Error) ?? .unknown(error)
}
.eraseToAnyPublisher()
}
}
extension ManagedObjectObserver {
private static func changeType(of object: NSManagedObject, in notification: ManagedObjectContextObjectsDidChangeNotification) -> ChangeType? {
let deleted = notification.deletedObjects.union(notification.invalidedObjects)
if notification.invalidatedAllObjects || deleted.contains(where: { $0 === object }) {
return .delete
}
let updated = notification.updatedObjects.union(notification.refreshedObjects)
if let object = updated.first(where: { $0 === object }) {
return .update(object)
}
return nil
}
}
extension ManagedObjectObserver {
public struct Change {
public let changeType: ChangeType?
public let changeNotification: ManagedObjectContextObjectsDidChangeNotification
init(changeType: ManagedObjectObserver.ChangeType?, changeNotification: ManagedObjectContextObjectsDidChangeNotification) {
self.changeType = changeType
self.changeNotification = changeNotification
}
}
public enum ChangeType {
case delete
case update(NSManagedObject)
}
public enum Error: Swift.Error {
case unknown(Swift.Error)
case noManagedObjectContext
case notManagedObjectChangeNotification
}
}

View File

@ -0,0 +1,33 @@
//
// CoreDataStackTests.swift
// CoreDataStackTests
//
// Created by MainasuK Cirno on 2021/1/27.
//
import XCTest
@testable import CoreDataStack
class CoreDataStackTests: XCTestCase {
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}
func testExample() throws {
// This is an example of a functional test case.
// Use XCTAssert and related functions to verify your tests produce the correct results.
}
func testPerformanceExample() throws {
// This is an example of a performance test case.
self.measure {
// Put the code you want to measure the time of here.
}
}
}

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
</plist>

8
Localization/README.md Normal file
View File

@ -0,0 +1,8 @@
# Localization
Mastodon localization template file
## How to contribute?
TBD

View File

@ -0,0 +1,25 @@
// swift-tools-version:5.2
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "StringsConvertor",
platforms: [
.macOS(.v10_15)
],
dependencies: [
// Dependencies declare other packages that this package depends on.
// .package(url: /* package url */, from: "1.0.0"),
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages which this package depends on.
.target(
name: "StringsConvertor",
dependencies: []),
.testTarget(
name: "StringsConvertorTests",
dependencies: ["StringsConvertor"]),
]
)

View File

@ -0,0 +1,12 @@
# StringsConvertor
Convert i18n JSON file to Stings file.
## Usage
```
chmod +x scripts/build.sh
./scripts/build.sh
# lproj files will locate in output/ directory
```

View File

@ -0,0 +1,100 @@
//
// File.swift
//
//
// Created by Cirno MainasuK on 2020-7-7.
//
import Foundation
class Parser {
let json: [String: Any]
init(data: Data) throws {
let dict = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any]
self.json = dict ?? [:]
}
}
extension Parser {
enum KeyStyle {
case infoPlist
case swiftgen
}
}
extension Parser {
func generateStrings(keyStyle: KeyStyle = .swiftgen) -> String {
let pairs = traval(dictionary: json, prefixKeys: [])
var lines: [String] = []
for pair in pairs {
let key = [
"\"",
pair.prefix
.map { segment in
segment
.split(separator: "_")
.map { String($0) }
.map {
switch keyStyle {
case .infoPlist: return $0
case .swiftgen: return $0.capitalized
}
}
.joined()
}
.joined(separator: "."),
"\""
].joined()
let value = [
"\"",
pair.value.replacingOccurrences(of: "%s", with: "%@"),
"\""
].joined()
let line = [
[key, value].joined(separator: " = "),
";"
].joined()
lines.append(line)
}
let strings = lines
.sorted()
.joined(separator: "\n")
return strings
}
}
extension Parser {
typealias PrefixKeys = [String]
typealias LocalizationPair = (prefix: PrefixKeys, value: String)
private func traval(dictionary: [String: Any], prefixKeys: PrefixKeys) -> [LocalizationPair] {
var pairs: [LocalizationPair] = []
for (key, any) in dictionary {
let prefix = prefixKeys + [key]
// if leaf node of dict tree
if let value = any as? String {
pairs.append(LocalizationPair(prefix: prefix, value: value))
continue
}
// if not leaf node of dict tree
if let dict = any as? [String: Any] {
let innerPairs = traval(dictionary: dict, prefixKeys: prefix)
pairs.append(contentsOf: innerPairs)
}
}
return pairs
}
}

View File

@ -0,0 +1,74 @@
import os.log
import Foundation
let currentFileURL = URL(fileURLWithPath: "\(#file)", isDirectory: false)
let packageRootURL = currentFileURL.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
let inputDirectoryURL = packageRootURL.appendingPathComponent("input", isDirectory: true)
let outputDirectoryURL = packageRootURL.appendingPathComponent("output", isDirectory: true)
private func convert(from inputDirectory: URL, to outputDirectory: URL) {
do {
let inputLanguageDirectoryURLs = try FileManager.default.contentsOfDirectory(
at: inputDirectoryURL,
includingPropertiesForKeys: [.nameKey, .isDirectoryKey],
options: []
)
for inputLanguageDirectoryURL in inputLanguageDirectoryURLs {
let language = inputLanguageDirectoryURL.lastPathComponent
guard let mappedLanguage = map(language: language) else { continue }
let outputDirectoryURL = outputDirectory.appendingPathComponent(mappedLanguage + ".lproj", isDirectory: true)
os_log("%{public}s[%{public}ld], %{public}s: process %s -> %s", ((#file as NSString).lastPathComponent), #line, #function, language, mappedLanguage)
let fileURLs = try FileManager.default.contentsOfDirectory(
at: inputLanguageDirectoryURL,
includingPropertiesForKeys: [.nameKey, .isDirectoryKey],
options: []
)
for jsonURL in fileURLs where jsonURL.pathExtension == "json" {
os_log("%{public}s[%{public}ld], %{public}s: process %s", ((#file as NSString).lastPathComponent), #line, #function, jsonURL.debugDescription)
let filename = jsonURL.deletingPathExtension().lastPathComponent
guard let (mappedFilename, keyStyle) = map(filename: filename) else { continue }
let outputFileURL = outputDirectoryURL.appendingPathComponent(mappedFilename).appendingPathExtension("strings")
let strings = try process(url: jsonURL, keyStyle: keyStyle)
try? FileManager.default.createDirectory(at: outputDirectoryURL, withIntermediateDirectories: true, attributes: nil)
try strings.write(to: outputFileURL, atomically: true, encoding: .utf8)
}
}
} catch {
os_log("%{public}s[%{public}ld], %{public}s: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
exit(1)
}
}
private func map(language: String) -> String? {
switch language {
case "en_US": return "en"
case "zh_CN": return "zh-Hans"
case "ja_JP": return "ja"
case "de_DE": return "de"
case "pt_BR": return "pt-BR"
default: return nil
}
}
private func map(filename: String) -> (filename: String, keyStyle: Parser.KeyStyle)? {
switch filename {
case "app": return ("Localizable", .swiftgen)
case "ios-infoPlist": return ("infoPlist", .infoPlist)
default: return nil
}
}
private func process(url: URL, keyStyle: Parser.KeyStyle) throws -> String {
do {
let data = try Data(contentsOf: url)
let parser = try Parser(data: data)
let strings = parser.generateStrings(keyStyle: keyStyle)
return strings
} catch {
os_log("%{public}s[%{public}ld], %{public}s: error: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
throw error
}
}
convert(from: inputDirectoryURL, to: outputDirectoryURL)

View File

@ -0,0 +1,7 @@
import XCTest
import StringsConvertorTests
var tests = [XCTestCaseEntry]()
tests += StringsConvertorTests.allTests()
XCTMain(tests)

View File

@ -0,0 +1,47 @@
import XCTest
import class Foundation.Bundle
final class StringsConvertorTests: XCTestCase {
func testExample() throws {
// This is an example of a functional test case.
// Use XCTAssert and related functions to verify your tests produce the correct
// results.
// Some of the APIs that we use below are available in macOS 10.13 and above.
guard #available(macOS 10.13, *) else {
return
}
let fooBinary = productsDirectory.appendingPathComponent("StringsConvertor")
let process = Process()
process.executableURL = fooBinary
let pipe = Pipe()
process.standardOutput = pipe
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8)
XCTAssertEqual(output, "Hello, world!\n")
}
/// Returns path to the built products directory.
var productsDirectory: URL {
#if os(macOS)
for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") {
return bundle.bundleURL.deletingLastPathComponent()
}
fatalError("couldn't find the products directory")
#else
return Bundle.main.bundleURL
#endif
}
static var allTests = [
("testExample", testExample),
]
}

View File

@ -3,7 +3,7 @@ import XCTest
#if !canImport(ObjectiveC)
public func allTests() -> [XCTestCaseEntry] {
return [
testCase(MastodonSDKTests.allTests),
testCase(StringsConvertorTests.allTests),
]
}
#endif

View File

@ -0,0 +1,28 @@
#!/bin/zsh
set -ev
# Crowin_Latest_Build="https://crowdin.com/backend/download/project/<TBD>.zip"
if [[ -d input ]]; then
rm -rf input
fi
if [[ -d output ]]; then
rm -rf output
fi
mkdir output
# FIXME: temporary use local json for i18n
# replace by the Crowdin remote template later
mkdir -p input/en_US
cp ../app.json ./input/en_US
cp ../ios-infoPlist.json ./input/en_US
# curl -o <TBD>.zip -L ${Crowin_Latest_Build}
# unzip -o -q <TBD>.zip -d input
# rm -rf <TBD>.zip
swift run

129
Localization/app.json Normal file
View File

@ -0,0 +1,129 @@
{
"common": {
"alerts": {
"sign_up_failure": {
"title": "Sign Up Failure"
},
"server_error": {
"title": "Server Error"
}
},
"controls": {
"actions": {
"back": "Back",
"add": "Add",
"remove": "Remove",
"edit": "Edit",
"save": "Save",
"ok": "OK",
"confirm": "Confirm",
"continue": "Continue",
"cancel": "Cancel",
"take_photo": "Take photo",
"save_photo": "Save photo",
"sign_in": "Sign In",
"sign_up": "Sign Up",
"see_more": "See More",
"preview": "Preview",
"open_in_safari": "Open in Safari"
},
"status": {
"user_boosted": "%s boosted",
"show_post": "Show Post",
"status_content_warning": "content warning",
"media_content_warning": "Tap to reveal that may be sensitive"
},
"timeline": {
"load_more": "Load More"
}
},
"countable": {
"photo": {
"single": "photo",
"multiple": "photos"
}
}
},
"scene": {
"welcome": {
"slogan": "Social networking\nback in your hands."
},
"server_picker": {
"title": "Pick a Server,\nany server.",
"Button": {
"Category": {
"All": "All"
},
"SeeLess": "See Less",
"SeeMore": "See More"
},
"Label": {
"Language": "LANGUAGE",
"Users": "USERS",
"Category": "CATEGORY"
},
"input": {
"placeholder": "Find a server or join your own..."
}
},
"register": {
"title": "Tell us about you.",
"input": {
"username": {
"placeholder": "username",
"duplicate_prompt": "This username is taken."
},
"display_name": {
"placeholder": "display name"
},
"email": {
"placeholder": "email"
},
"password": {
"placeholder": "password",
"prompt": "Your password needs at least:",
"prompt_eight_characters": "Eight characters"
},
"invite": {
"registration_user_invite_request": "Why do you want to join?"
}
},
"success": "Success",
"check_email": "Regsiter request sent. Please check your email."
},
"server_rules": {
"title": "Some ground rules.",
"subtitle": "These rules are set by the admins of %s.",
"prompt": "By continuing, you're subject to the terms of service and privacy policy for %s.",
"button": {
"confirm": "I Agree"
}
},
"confirm_email": {
"title": "One last thing.",
"subtitle": "We just sent an email to %@,\ntap the link to confirm your account.",
"button": {
"open_email_app": "Open Email App",
"dont_receive_email": "I never got an email"
},
"dont_receive_email": {
"title": "Check your email",
"description": "Check if your email address is correct as well as your junk folder if you havent.",
"resend_email": "Resend Email"
},
"open_email_app": {
"title": "Check your inbox.",
"description": "We just sent you an email. Check your junk folder if you havent.",
"mail": "Mail",
"open_email_client": "Open Email Client"
}
},
"home_timeline": {
"title": "Home"
},
"public_timeline": {
"title": "Public"
}
}
}

View File

@ -0,0 +1,4 @@
{
"NSCameraUsageDescription": "Used to take photo for toot",
"NSPhotoLibraryAddUsageDescription": "Used to save photo into the Photo Library"
}

File diff suppressed because it is too large Load Diff

View File

@ -4,10 +4,48 @@
<dict>
<key>SchemeUserState</key>
<dict>
<key>CoreDataStack.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>10</integer>
</dict>
<key>Mastodon - RTL.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>9</integer>
</dict>
<key>Mastodon - Release.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
</dict>
<key>Mastodon.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>5</integer>
<integer>0</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict>
<key>DB427DD125BAA00100D1B89D</key>
<dict>
<key>primary</key>
<true/>
</dict>
<key>DB427DE725BAA00100D1B89D</key>
<dict>
<key>primary</key>
<true/>
</dict>
<key>DB427DF225BAA00100D1B89D</key>
<dict>
<key>primary</key>
<true/>
</dict>
<key>DB89B9F525C10FD0008580ED</key>
<dict>
<key>primary</key>
<true/>
</dict>
</dict>
</dict>

View File

@ -1,6 +1,15 @@
{
"object": {
"pins": [
{
"package": "ActiveLabel",
"repositoryURL": "https://github.com/TwidereProject/ActiveLabel.swift",
"state": {
"branch": null,
"revision": "d6cf96e0ca4f2269021bcf8f11381ab57897f84a",
"version": "4.0.0"
}
},
{
"package": "Alamofire",
"repositoryURL": "https://github.com/Alamofire/Alamofire.git",
@ -18,6 +27,69 @@
"revision": "3e8edbeb75227f8542aa87f90240cf0424d6362f",
"version": "4.1.0"
}
},
{
"package": "AlamofireNetworkActivityIndicator",
"repositoryURL": "https://github.com/Alamofire/AlamofireNetworkActivityIndicator",
"state": {
"branch": null,
"revision": "392bed083e8d193aca16bfa684ee24e4bcff0510",
"version": "3.1.0"
}
},
{
"package": "CommonOSLog",
"repositoryURL": "https://github.com/MainasuK/CommonOSLog",
"state": {
"branch": null,
"revision": "c121624a30698e9886efe38aebb36ff51c01b6c2",
"version": "0.1.1"
}
},
{
"package": "Kingfisher",
"repositoryURL": "https://github.com/onevcat/Kingfisher.git",
"state": {
"branch": null,
"revision": "daebf8ddf974164d1b9a050c8231e263f3106b09",
"version": "6.1.0"
}
},
{
"package": "swift-nio",
"repositoryURL": "https://github.com/apple/swift-nio.git",
"state": {
"branch": null,
"revision": "8da5c5a4e6c5084c296b9f39dc54f00be146e0fa",
"version": "1.14.2"
}
},
{
"package": "swift-nio-zlib-support",
"repositoryURL": "https://github.com/apple/swift-nio-zlib-support.git",
"state": {
"branch": null,
"revision": "37760e9a52030bb9011972c5213c3350fa9d41fd",
"version": "1.0.0"
}
},
{
"package": "SwiftyJSON",
"repositoryURL": "https://github.com/SwiftyJSON/SwiftyJSON.git",
"state": {
"branch": null,
"revision": "2b6054efa051565954e1d2b9da831680026cd768",
"version": "5.0.0"
}
},
{
"package": "ThirdPartyMailer",
"repositoryURL": "https://github.com/vtourraine/ThirdPartyMailer.git",
"state": {
"branch": null,
"revision": "923c60ee7588da47db8cfc4e0f5b96e5e605ef84",
"version": "1.7.1"
}
}
]
},

View File

@ -0,0 +1,28 @@
//
// NeedsDependency.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021-1-27.
//
import UIKit
protocol NeedsDependency: class {
var context: AppContext! { get set }
var coordinator: SceneCoordinator! { get set }
}
extension UISceneSession {
private struct AssociatedKeys {
static var sceneCoordinator = "SceneCoordinator"
}
weak var sceneCoordinator: SceneCoordinator? {
get {
return objc_getAssociatedObject(self, &AssociatedKeys.sceneCoordinator) as? SceneCoordinator
}
set {
objc_setAssociatedObject(self, &AssociatedKeys.sceneCoordinator, newValue, .OBJC_ASSOCIATION_ASSIGN)
}
}
}

View File

@ -0,0 +1,220 @@
//
// SceneCoordinator.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021-1-27.
import UIKit
import SafariServices
import CoreDataStack
final public class SceneCoordinator {
private weak var scene: UIScene!
private weak var sceneDelegate: SceneDelegate!
private weak var appContext: AppContext!
let id = UUID().uuidString
init(scene: UIScene, sceneDelegate: SceneDelegate, appContext: AppContext) {
self.scene = scene
self.sceneDelegate = sceneDelegate
self.appContext = appContext
scene.session.sceneCoordinator = self
}
}
extension SceneCoordinator {
enum Transition {
case show // push
case showDetail // replace
case modal(animated: Bool, completion: (() -> Void)? = nil)
case custom(transitioningDelegate: UIViewControllerTransitioningDelegate)
case customPush
case safariPresent(animated: Bool, completion: (() -> Void)? = nil)
case activityViewControllerPresent(animated: Bool, completion: (() -> Void)? = nil)
case alertController(animated: Bool, completion: (() -> Void)? = nil)
}
enum Scene {
// onboarding
case welcome
case mastodonPickServer(viewMode: MastodonPickServerViewModel)
case mastodonPinBasedAuthentication(viewModel: MastodonPinBasedAuthenticationViewModel)
case mastodonRegister(viewModel: MastodonRegisterViewModel)
case mastodonServerRules(viewModel: MastodonServerRulesViewModel)
case mastodonConfirmEmail(viewModel: MastodonConfirmEmailViewModel)
case mastodonResendEmail(viewModel: MastodonResendEmailViewModel)
// misc
case alertController(alertController: UIAlertController)
#if DEBUG
case publicTimeline
#endif
var isOnboarding: Bool {
switch self {
case .welcome,
.mastodonPickServer,
.mastodonPinBasedAuthentication,
.mastodonRegister,
.mastodonServerRules,
.mastodonConfirmEmail,
.mastodonResendEmail:
return true
default:
return false
}
}
}
}
extension SceneCoordinator {
func setup() {
let viewController = MainTabBarController(context: appContext, coordinator: self)
sceneDelegate.window?.rootViewController = viewController
}
func setupOnboardingIfNeeds(animated: Bool) {
// Check user authentication status and show onboarding if needs
do {
let request = MastodonAuthentication.sortedFetchRequest
if try appContext.managedObjectContext.fetch(request).isEmpty {
DispatchQueue.main.async {
self.present(
scene: .welcome,
from: nil,
transition: .modal(animated: animated, completion: nil)
)
}
}
} catch {
assertionFailure(error.localizedDescription)
}
}
@discardableResult
func present(scene: Scene, from sender: UIViewController?, transition: Transition) -> UIViewController? {
guard let viewController = get(scene: scene) else {
return nil
}
guard var presentingViewController = sender ?? sceneDelegate.window?.rootViewController?.topMost else {
return nil
}
if let mainTabBarController = presentingViewController as? MainTabBarController,
let navigationController = mainTabBarController.selectedViewController as? UINavigationController,
let topViewController = navigationController.topViewController {
presentingViewController = topViewController
}
switch transition {
case .show:
presentingViewController.show(viewController, sender: sender)
case .showDetail:
let navigationController = UINavigationController(rootViewController: viewController)
presentingViewController.showDetailViewController(navigationController, sender: sender)
case .modal(let animated, let completion):
let modalNavigationController: UINavigationController = {
if scene.isOnboarding {
return DarkContentStatusBarStyleNavigationController(rootViewController: viewController)
} else {
return UINavigationController(rootViewController: viewController)
}
}()
if let adaptivePresentationControllerDelegate = viewController as? UIAdaptivePresentationControllerDelegate {
modalNavigationController.presentationController?.delegate = adaptivePresentationControllerDelegate
}
presentingViewController.present(modalNavigationController, animated: animated, completion: completion)
case .custom(let transitioningDelegate):
viewController.modalPresentationStyle = .custom
viewController.transitioningDelegate = transitioningDelegate
sender?.present(viewController, animated: true, completion: nil)
case .customPush:
// set delegate in view controller
assert(sender?.navigationController?.delegate != nil)
sender?.navigationController?.pushViewController(viewController, animated: true)
case .safariPresent(let animated, let completion):
presentingViewController.present(viewController, animated: animated, completion: completion)
case .activityViewControllerPresent(let animated, let completion):
presentingViewController.present(viewController, animated: animated, completion: completion)
case .alertController(let animated, let completion):
presentingViewController.present(viewController, animated: animated, completion: completion)
}
return viewController
}
}
private extension SceneCoordinator {
func get(scene: Scene) -> UIViewController? {
let viewController: UIViewController?
switch scene {
case .welcome:
let _viewController = WelcomeViewController()
viewController = _viewController
case .mastodonPickServer(let viewModel):
let _viewController = MastodonPickServerViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .mastodonPinBasedAuthentication(let viewModel):
let _viewController = MastodonPinBasedAuthenticationViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .mastodonRegister(let viewModel):
let _viewController = MastodonRegisterViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .mastodonServerRules(let viewModel):
let _viewController = MastodonServerRulesViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .mastodonConfirmEmail(let viewModel):
let _viewController = MastodonConfirmEmailViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .mastodonResendEmail(let viewModel):
let _viewController = MastodonResendEmailViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .alertController(let alertController):
if let popoverPresentationController = alertController.popoverPresentationController {
assert(
popoverPresentationController.sourceView != nil ||
popoverPresentationController.sourceRect != .zero ||
popoverPresentationController.barButtonItem != nil
)
}
viewController = alertController
#if DEBUG
case .publicTimeline:
let _viewController = PublicTimelineViewController()
_viewController.viewModel = PublicTimelineViewModel(context: appContext)
viewController = _viewController
#endif
}
setupDependency(for: viewController as? NeedsDependency)
return viewController
}
private func setupDependency(for needs: NeedsDependency?) {
needs?.context = appContext
needs?.coordinator = self
}
}

View File

@ -0,0 +1,94 @@
//
// Item.swift
// Mastodon
//
// Created by sxiaojian on 2021/1/27.
//
import CoreData
import CoreDataStack
import Foundation
import MastodonSDK
/// Note: update Equatable when change case
enum Item {
// timeline
case homeTimelineIndex(objectID: NSManagedObjectID, attribute: StatusTimelineAttribute)
// normal list
case toot(objectID: NSManagedObjectID, attribute: StatusTimelineAttribute)
// loader
case homeMiddleLoader(upperTimelineIndexAnchorObjectID: NSManagedObjectID)
case publicMiddleLoader(tootID: String)
case bottomLoader
}
protocol StatusContentWarningAttribute {
var isStatusTextSensitive: Bool { get set }
var isStatusSensitive: Bool { get set }
}
extension Item {
class StatusTimelineAttribute: Hashable, StatusContentWarningAttribute {
var isStatusTextSensitive: Bool
var isStatusSensitive: Bool
public init(
isStatusTextSensitive: Bool,
isStatusSensitive: Bool
) {
self.isStatusTextSensitive = isStatusTextSensitive
self.isStatusSensitive = isStatusSensitive
}
static func == (lhs: Item.StatusTimelineAttribute, rhs: Item.StatusTimelineAttribute) -> Bool {
return lhs.isStatusTextSensitive == rhs.isStatusTextSensitive &&
lhs.isStatusSensitive == rhs.isStatusSensitive
}
func hash(into hasher: inout Hasher) {
hasher.combine(isStatusTextSensitive)
hasher.combine(isStatusSensitive)
}
}
}
extension Item: Equatable {
static func == (lhs: Item, rhs: Item) -> Bool {
switch (lhs, rhs) {
case (.homeTimelineIndex(let objectIDLeft, _), .homeTimelineIndex(let objectIDRight, _)):
return objectIDLeft == objectIDRight
case (.toot(let objectIDLeft, _), .toot(let objectIDRight, _)):
return objectIDLeft == objectIDRight
case (.bottomLoader, .bottomLoader):
return true
case (.publicMiddleLoader(let upperLeft), .publicMiddleLoader(let upperRight)):
return upperLeft == upperRight
case (.homeMiddleLoader(let upperLeft), .homeMiddleLoader(let upperRight)):
return upperLeft == upperRight
default:
return false
}
}
}
extension Item: Hashable {
func hash(into hasher: inout Hasher) {
switch self {
case .homeTimelineIndex(let objectID, _):
hasher.combine(objectID)
case .toot(let objectID, _):
hasher.combine(objectID)
case .publicMiddleLoader(let upper):
hasher.combine(String(describing: Item.publicMiddleLoader.self))
hasher.combine(upper)
case .homeMiddleLoader(upperTimelineIndexAnchorObjectID: let upper):
hasher.combine(String(describing: Item.homeMiddleLoader.self))
hasher.combine(upper)
case .bottomLoader:
hasher.combine(String(describing: Item.bottomLoader.self))
}
}
}

View File

@ -0,0 +1,207 @@
//
// TimelineSection.swift
// Mastodon
//
// Created by sxiaojian on 2021/1/27.
//
import Combine
import CoreData
import CoreDataStack
import os.log
import UIKit
enum StatusSection: Equatable, Hashable {
case main
}
extension StatusSection {
static func tableViewDiffableDataSource(
for tableView: UITableView,
dependency: NeedsDependency,
managedObjectContext: NSManagedObjectContext,
timestampUpdatePublisher: AnyPublisher<Date, Never>,
timelinePostTableViewCellDelegate: StatusTableViewCellDelegate,
timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate?
) -> UITableViewDiffableDataSource<StatusSection, Item> {
UITableViewDiffableDataSource(tableView: tableView) { [weak timelinePostTableViewCellDelegate, weak timelineMiddleLoaderTableViewCellDelegate] tableView, indexPath, item -> UITableViewCell? in
guard let timelinePostTableViewCellDelegate = timelinePostTableViewCellDelegate else { return UITableViewCell() }
switch item {
case .homeTimelineIndex(objectID: let objectID, let attribute):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell
// configure cell
managedObjectContext.performAndWait {
let timelineIndex = managedObjectContext.object(with: objectID) as! HomeTimelineIndex
StatusSection.configure(cell: cell, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, toot: timelineIndex.toot, requestUserID: timelineIndex.userID, statusContentWarningAttribute: attribute)
}
cell.delegate = timelinePostTableViewCellDelegate
return cell
case .toot(let objectID, let attribute):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell
let activeMastodonAuthenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value
let requestUserID = activeMastodonAuthenticationBox?.userID ?? ""
// configure cell
managedObjectContext.performAndWait {
let toot = managedObjectContext.object(with: objectID) as! Toot
StatusSection.configure(cell: cell, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, toot: toot, requestUserID: requestUserID, statusContentWarningAttribute: attribute)
}
cell.delegate = timelinePostTableViewCellDelegate
return cell
case .publicMiddleLoader(let upperTimelineTootID):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self), for: indexPath) as! TimelineMiddleLoaderTableViewCell
cell.delegate = timelineMiddleLoaderTableViewCellDelegate
timelineMiddleLoaderTableViewCellDelegate?.configure(cell: cell, upperTimelineTootID: upperTimelineTootID, timelineIndexobjectID: nil)
return cell
case .homeMiddleLoader(let upperTimelineIndexObjectID):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self), for: indexPath) as! TimelineMiddleLoaderTableViewCell
cell.delegate = timelineMiddleLoaderTableViewCellDelegate
timelineMiddleLoaderTableViewCellDelegate?.configure(cell: cell, upperTimelineTootID: nil, timelineIndexobjectID: upperTimelineIndexObjectID)
return cell
case .bottomLoader:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell
cell.activityIndicatorView.startAnimating()
return cell
}
}
}
static func configure(
cell: StatusTableViewCell,
readableLayoutFrame: CGRect?,
timestampUpdatePublisher: AnyPublisher<Date, Never>,
toot: Toot,
requestUserID: String,
statusContentWarningAttribute: StatusContentWarningAttribute?
) {
// set header
cell.statusView.headerContainerStackView.isHidden = toot.reblog == nil
cell.statusView.headerInfoLabel.text = {
let author = toot.author
let name = author.displayName.isEmpty ? author.username : author.displayName
return L10n.Common.Controls.Status.userBoosted(name)
}()
// set name username avatar
cell.statusView.nameLabel.text = {
let author = (toot.reblog ?? toot).author
return author.displayName.isEmpty ? author.username : author.displayName
}()
cell.statusView.usernameLabel.text = "@" + (toot.reblog ?? toot).author.acct
cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: (toot.reblog ?? toot).author.avatarImageURL()))
// set text
cell.statusView.activeTextLabel.config(content: (toot.reblog ?? toot).content)
// set status text content warning
let spoilerText = (toot.reblog ?? toot).spoilerText ?? ""
let isStatusTextSensitive = statusContentWarningAttribute?.isStatusTextSensitive ?? !spoilerText.isEmpty
cell.statusView.isStatusTextSensitive = isStatusTextSensitive
cell.statusView.updateContentWarningDisplay(isHidden: !isStatusTextSensitive)
cell.statusView.contentWarningTitle.text = {
if spoilerText.isEmpty {
return L10n.Common.Controls.Status.statusContentWarning
} else {
return L10n.Common.Controls.Status.statusContentWarning + ": \(spoilerText)"
}
}()
// prepare media attachments
let mediaAttachments = Array((toot.reblog ?? toot).mediaAttachments ?? []).sorted { $0.index.compare($1.index) == .orderedAscending }
// set image
let mosiacImageViewModel = MosaicImageViewModel(mediaAttachments: mediaAttachments)
let imageViewMaxSize: CGSize = {
let maxWidth: CGFloat = {
// use timelinePostView width as container width
// that width follows readable width and keep constant width after rotate
let containerFrame = readableLayoutFrame ?? cell.statusView.frame
var containerWidth = containerFrame.width
containerWidth -= 10
containerWidth -= StatusView.avatarImageSize.width
return containerWidth
}()
let scale: CGFloat = {
switch mosiacImageViewModel.metas.count {
case 1: return 1.3
default: return 0.7
}
}()
return CGSize(width: maxWidth, height: maxWidth * scale)
}()
if mosiacImageViewModel.metas.count == 1 {
let meta = mosiacImageViewModel.metas[0]
let imageView = cell.statusView.statusMosaicImageView.setupImageView(aspectRatio: meta.size, maxSize: imageViewMaxSize)
imageView.af.setImage(
withURL: meta.url,
placeholderImage: UIImage.placeholder(color: .systemFill),
imageTransition: .crossDissolve(0.2)
)
} else {
let imageViews = cell.statusView.statusMosaicImageView.setupImageViews(count: mosiacImageViewModel.metas.count, maxHeight: imageViewMaxSize.height)
for (i, imageView) in imageViews.enumerated() {
let meta = mosiacImageViewModel.metas[i]
imageView.af.setImage(
withURL: meta.url,
placeholderImage: UIImage.placeholder(color: .systemFill),
imageTransition: .crossDissolve(0.2)
)
}
}
cell.statusView.statusMosaicImageView.isHidden = mosiacImageViewModel.metas.isEmpty
let isStatusSensitive = statusContentWarningAttribute?.isStatusSensitive ?? (toot.reblog ?? toot).sensitive
cell.statusView.statusMosaicImageView.blurVisualEffectView.effect = isStatusSensitive ? MosaicImageViewContainer.blurVisualEffect : nil
cell.statusView.statusMosaicImageView.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0
// toolbar
let replyCountTitle: String = {
let count = (toot.reblog ?? toot).repliesCount?.intValue ?? 0
return StatusSection.formattedNumberTitleForActionButton(count)
}()
cell.statusView.actionToolbarContainer.replyButton.setTitle(replyCountTitle, for: .normal)
let isLike = (toot.reblog ?? toot).favouritedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false
let favoriteCountTitle: String = {
let count = (toot.reblog ?? toot).favouritesCount.intValue
return StatusSection.formattedNumberTitleForActionButton(count)
}()
cell.statusView.actionToolbarContainer.starButton.setTitle(favoriteCountTitle, for: .normal)
cell.statusView.actionToolbarContainer.isStarButtonHighlight = isLike
// set date
let createdAt = (toot.reblog ?? toot).createdAt
cell.statusView.dateLabel.text = createdAt.shortTimeAgoSinceNow
timestampUpdatePublisher
.sink { _ in
cell.statusView.dateLabel.text = createdAt.shortTimeAgoSinceNow
}
.store(in: &cell.disposeBag)
// observe model change
ManagedObjectObserver.observe(object: toot.reblog ?? toot)
.receive(on: DispatchQueue.main)
.sink { _ in
// do nothing
} receiveValue: { change in
guard case .update(let object) = change.changeType,
let newToot = object as? Toot else { return }
let targetToot = newToot.reblog ?? newToot
let isLike = targetToot.favouritedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false
let favoriteCount = targetToot.favouritesCount.intValue
let favoriteCountTitle = StatusSection.formattedNumberTitleForActionButton(favoriteCount)
cell.statusView.actionToolbarContainer.starButton.setTitle(favoriteCountTitle, for: .normal)
cell.statusView.actionToolbarContainer.isStarButtonHighlight = isLike
os_log("%{public}s[%{public}ld], %{public}s: like count label for toot %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, targetToot.id, favoriteCount)
}
.store(in: &cell.disposeBag)
}
}
extension StatusSection {
private static func formattedNumberTitleForActionButton(_ number: Int?) -> String {
guard let number = number, number > 0 else { return "" }
return String(number)
}
}

View File

@ -0,0 +1,53 @@
//
// ActiveLabel.swift
// Mastodon
//
// Created by sxiaojian on 2021/1/29.
//
import UIKit
import Foundation
import ActiveLabel
import os.log
extension ActiveLabel {
enum Style {
case `default`
case timelineHeaderView
}
convenience init(style: Style) {
self.init()
switch style {
case .default:
font = .preferredFont(forTextStyle: .body)
textColor = Asset.Colors.Label.primary.color
case .timelineHeaderView:
font = .preferredFont(forTextStyle: .footnote)
textColor = .secondaryLabel
}
numberOfLines = 0
lineSpacing = 5
mentionColor = Asset.Colors.Label.highlight.color
hashtagColor = Asset.Colors.Label.highlight.color
URLColor = Asset.Colors.Label.highlight.color
text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."
}
}
extension ActiveLabel {
func config(content: String) {
activeEntities.removeAll()
if let parseResult = try? TootContent.parse(toot: content) {
text = parseResult.trimmed
activeEntities = parseResult.activeEntities
} else {
text = ""
}
}
}

View File

@ -0,0 +1,23 @@
//
// Attachment.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-2-23.
//
import Foundation
import CoreDataStack
import MastodonSDK
extension Attachment {
var type: Mastodon.Entity.Attachment.AttachmentType {
return Mastodon.Entity.Attachment.AttachmentType(rawValue: typeRaw) ?? ._other(typeRaw)
}
var meta: Mastodon.Entity.Attachment.Meta? {
let decoder = JSONDecoder()
return metaData.flatMap { try? decoder.decode(Mastodon.Entity.Attachment.Meta.self, from: $0) }
}
}

View File

@ -0,0 +1,32 @@
//
// MastodonUser.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021/2/3.
//
import Foundation
import CoreDataStack
import MastodonSDK
extension MastodonUser.Property {
init(entity: Mastodon.Entity.Account, domain: String, networkDate: Date) {
self.init(
id: entity.id,
domain: domain,
acct: entity.acct,
username: entity.username,
displayName: entity.displayName,
avatar: entity.avatar,
avatarStatic: entity.avatarStatic,
createdAt: entity.createdAt,
networkDate: networkDate
)
}
}
extension MastodonUser {
public func avatarImageURL() -> URL? {
return URL(string: avatar)
}
}

View File

@ -0,0 +1,34 @@
//
// Toot.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021/2/4.
//
import Foundation
import CoreDataStack
import MastodonSDK
extension Toot.Property {
init(entity: Mastodon.Entity.Status, domain: String, networkDate: Date) {
self.init(
domain: domain,
id: entity.id,
uri: entity.uri,
createdAt: entity.createdAt,
content: entity.content,
visibility: entity.visibility?.rawValue,
sensitive: entity.sensitive ?? false,
spoilerText: entity.spoilerText,
reblogsCount: NSNumber(value: entity.reblogsCount),
favouritesCount: NSNumber(value: entity.favouritesCount),
repliesCount: entity.repliesCount.flatMap { NSNumber(value: $0) },
url: entity.uri,
inReplyToID: entity.inReplyToID,
inReplyToAccountID: entity.inReplyToAccountID,
language: entity.language,
text: entity.text,
networkDate: networkDate
)
}
}

View File

@ -0,0 +1,316 @@
//
// MastodonContent.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021/2/1.
//
import Foundation
import Kanna
import ActiveLabel
enum TootContent {
static func parse(toot: String) throws -> TootContent.ParseResult {
let toot = toot.replacingOccurrences(of: "<br/>", with: "\n")
let rootNode = try Node.parse(document: toot)
let text = String(rootNode.text)
var activeEntities: [ActiveEntity] = []
let entities = TootContent.Node.entities(in: rootNode)
for entity in entities {
let range = NSRange(entity.text.startIndex..<entity.text.endIndex, in: text)
switch entity.type {
case .url:
guard let href = entity.href else { continue }
let text = String(entity.text)
activeEntities.append(ActiveEntity(range: range, type: .url(text, trimmed: entity.hrefEllipsis ?? text, url: href)))
case .hashtag:
var userInfo: [AnyHashable: Any] = [:]
entity.href.flatMap { href in
userInfo["href"] = href
}
let hashtag = String(entity.text).deletingPrefix("#")
activeEntities.append(ActiveEntity(range: range, type: .hashtag(hashtag, userInfo: userInfo)))
case .mention:
var userInfo: [AnyHashable: Any] = [:]
entity.href.flatMap { href in
userInfo["href"] = href
}
let mention = String(entity.text).deletingPrefix("@")
activeEntities.append(ActiveEntity(range: range, type: .mention(mention, userInfo: userInfo)))
default:
continue
}
}
var trimmed = text
for activeEntity in activeEntities {
guard case .url = activeEntity.type else { continue }
TootContent.trimEntity(toot: &trimmed, activeEntity: activeEntity, activeEntities: activeEntities)
}
return ParseResult(
document: toot,
original: text,
trimmed: trimmed,
activeEntities: validate(text: trimmed, activeEntities: activeEntities) ? activeEntities : []
)
}
static func trimEntity(toot: inout String, activeEntity: ActiveEntity, activeEntities: [ActiveEntity]) {
guard case let .url(text, trimmed, _, _) = activeEntity.type else { return }
guard let index = activeEntities.firstIndex(where: { $0.range == activeEntity.range }) else { return }
guard let range = Range(activeEntity.range, in: toot) else { return }
toot.replaceSubrange(range, with: trimmed)
let offset = trimmed.count - text.count
activeEntity.range.length += offset
let moveActiveEntities = Array(activeEntities[index...].dropFirst())
for moveActiveEntity in moveActiveEntities {
moveActiveEntity.range.location += offset
}
}
private static func validate(text: String, activeEntities: [ActiveEntity]) -> Bool {
for activeEntity in activeEntities {
let count = text.utf16.count
let endIndex = activeEntity.range.location + activeEntity.range.length
guard endIndex <= count else {
assertionFailure("Please file issue")
return false
}
}
return true
}
}
extension String {
// ref: https://www.hackingwithswift.com/example-code/strings/how-to-remove-a-prefix-from-a-string
func deletingPrefix(_ prefix: String) -> String {
guard self.hasPrefix(prefix) else { return self }
return String(self.dropFirst(prefix.count))
}
}
extension TootContent {
struct ParseResult {
let document: String
let original: String
let trimmed: String
let activeEntities: [ActiveEntity]
}
}
extension TootContent {
class Node {
let level: Int
let type: Type?
// substring text
let text: Substring
// range in parent String
var range: Range<String.Index> {
return text.startIndex..<text.endIndex
}
let tagName: String?
let classNames: Set<String>
let href: String?
let hrefEllipsis: String?
let children: [Node]
init(
level: Int,
text: Substring,
tagName: String?,
className: String?,
href: String?,
hrefEllipsis: String?,
children: [Node]
) {
let _classNames: Set<String> = {
guard let className = className else { return Set() }
return Set(className.components(separatedBy: " "))
}()
let _type: Type? = {
if tagName == "a" && !_classNames.contains("mention") {
return .url
}
if _classNames.contains("mention") {
if _classNames.contains("u-url") {
return .mention
} else if _classNames.contains("hashtag") {
return .hashtag
}
}
return nil
}()
self.level = level
self.type = _type
self.text = text
self.tagName = tagName
self.classNames = _classNames
self.href = href
self.hrefEllipsis = hrefEllipsis
self.children = children
}
static func parse(document: String) throws -> TootContent.Node {
let html = try HTML(html: document, encoding: .utf8)
let body = html.body ?? nil
let text = body?.text ?? ""
let level = 0
let children: [TootContent.Node] = body.flatMap { body in
return Node.parse(element: body, parentText: text[...], parentLevel: level + 1)
} ?? []
let node = Node(
level: level,
text: text[...],
tagName: body?.tagName,
className: body?.className,
href: nil,
hrefEllipsis: nil,
children: children
)
return node
}
static func parse(element: XMLElement, parentText: Substring, parentLevel: Int) -> [Node] {
let parent = element
let scanner = Scanner(string: String(parentText))
scanner.charactersToBeSkipped = .none
var element = parent.at_css(":first-child")
var children: [Node] = []
while let _element = element {
let _text = _element.text ?? ""
// scan element text
_ = scanner.scanUpToString(_text)
let startIndexOffset = scanner.currentIndex.utf16Offset(in: scanner.string)
guard scanner.scanString(_text) != nil else {
assertionFailure()
continue
}
let endIndexOffset = scanner.currentIndex.utf16Offset(in: scanner.string)
// locate substring
let startIndex = parentText.utf16.index(parentText.utf16.startIndex, offsetBy: startIndexOffset)
let endIndex = parentText.utf16.index(parentText.utf16.startIndex, offsetBy: endIndexOffset)
let text = Substring(parentText.utf16[startIndex..<endIndex])
let href = _element["href"]
let hrefEllipsis = href.flatMap { _ in _element.at_css(".ellipsis")?.text }
let level = parentLevel + 1
let node = Node(
level: level,
text: text,
tagName: _element.tagName,
className: _element.className,
href: href,
hrefEllipsis: hrefEllipsis,
children: Node.parse(element: _element, parentText: text, parentLevel: level + 1)
)
children.append(node)
element = _element.nextSibling
}
return children
}
static func collect(
node: Node,
where predicate: (Node) -> Bool
) -> [Node] {
var nodes: [Node] = []
if predicate(node) {
nodes.append(node)
}
for child in node.children {
nodes.append(contentsOf: Node.collect(node: child, where: predicate))
}
return nodes
}
}
}
extension TootContent.Node {
enum `Type` {
case url
case mention
case hashtag
}
static func entities(in node: TootContent.Node) -> [TootContent.Node] {
return TootContent.Node.collect(node: node) { node in node.type != nil }
}
static func hashtags(in node: TootContent.Node) -> [TootContent.Node] {
return TootContent.Node.collect(node: node) { node in node.type == .hashtag }
}
static func mentions(in node: TootContent.Node) -> [TootContent.Node] {
return TootContent.Node.collect(node: node) { node in node.type == .mention }
}
static func urls(in node: TootContent.Node) -> [TootContent.Node] {
return TootContent.Node.collect(node: node) { node in node.type == .url }
}
}
extension TootContent.Node: CustomDebugStringConvertible {
var debugDescription: String {
let linkInfo: String = {
switch (href, hrefEllipsis) {
case (nil, nil):
return ""
case (let href, let hrefEllipsis):
return "(\(href ?? "nil") - \(hrefEllipsis ?? "nil"))"
}
}()
let classNamesInfo: String = {
guard !classNames.isEmpty else { return "" }
let names = Array(classNames)
.sorted()
.joined(separator: ", ")
return "@[\(names)]"
}()
let nodeDescription = String(
format: "<%@>%@%@: %@",
tagName ?? "",
classNamesInfo,
linkInfo,
String(text)
)
guard !children.isEmpty else {
return nodeDescription
}
let indent = Array(repeating: " ", count: level).joined()
let childrenDescription = children
.map { indent + $0.debugDescription }
.joined(separator: "\n")
return nodeDescription + "\n" + childrenDescription
}
}

View File

@ -0,0 +1,14 @@
//
// NSKeyValueObservation.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021-2-24.
//
import Foundation
extension NSKeyValueObservation {
func store(in set: inout Set<NSKeyValueObservation>) {
set.insert(self)
}
}

View File

@ -0,0 +1,15 @@
//
// NSLayoutConstraint.swift
// Mastodon
//
// Created by sxiaojian on 2021/1/28.
//
import UIKit
extension NSLayoutConstraint {
func priority(_ priority: UILayoutPriority) -> Self {
self.priority = priority
return self
}
}

View File

@ -0,0 +1,20 @@
//
// OSLog.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021/1/29
//
import os
import Foundation
import CommonOSLog
extension OSLog {
static let api: OSLog = {
#if DEBUG
return OSLog(subsystem: OSLog.subsystem + ".api", category: "api")
#else
return OSLog.disabled
#endif
}()
}

View File

@ -0,0 +1,45 @@
//
// UIAlertController.swift
// Mastodon
//
import UIKit
// Reference:
// https://nshipster.com/swift-foundation-error-protocols/
extension UIAlertController {
convenience init(
for error: Error,
title: String?,
preferredStyle: UIAlertController.Style
) {
let _title: String
let message: String?
if let error = error as? LocalizedError {
var messages: [String?] = []
if let title = title {
_title = title
messages.append(error.errorDescription)
} else {
_title = error.errorDescription ?? "Error"
}
messages.append(contentsOf: [
error.failureReason,
error.recoverySuggestion
])
message = messages
.compactMap { $0 }
.joined(separator: " ")
} else {
_title = "Internal Error"
message = error.localizedDescription
}
self.init(
title: _title,
message: message,
preferredStyle: preferredStyle
)
}
}

View File

@ -0,0 +1,26 @@
//
// UIApplication.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021-2-26.
//
import UIKit
extension UIApplication {
class func appVersion() -> String {
return Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as! String
}
class func appBuild() -> String {
return Bundle.main.object(forInfoDictionaryKey: kCFBundleVersionKey as String) as! String
}
class func versionBuild() -> String {
let version = appVersion(), build = appBuild()
return version == build ? "v\(version)" : "v\(version) (\(build))"
}
}

View File

@ -0,0 +1,20 @@
//
// UIBarButtonItem.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021/2/3.
//
import UIKit
extension UIBarButtonItem {
static var activityIndicatorBarButtonItem: UIBarButtonItem {
let activityIndicatorView = UIActivityIndicatorView(style: .medium)
let barButtonItem = UIBarButtonItem(customView: activityIndicatorView)
activityIndicatorView.startAnimating()
return barButtonItem
}
}

View File

@ -0,0 +1,45 @@
//
// UIButton.swift
// Mastodon
//
// Created by sxiaojian on 2021/2/1.
//
import UIKit
extension UIButton {
func setInsets(
forContentPadding contentPadding: UIEdgeInsets,
imageTitlePadding: CGFloat
) {
switch UIApplication.shared.userInterfaceLayoutDirection {
case .rightToLeft:
self.contentEdgeInsets = UIEdgeInsets(
top: contentPadding.top,
left: contentPadding.left + imageTitlePadding,
bottom: contentPadding.bottom,
right: contentPadding.right
)
self.titleEdgeInsets = UIEdgeInsets(
top: 0,
left: -imageTitlePadding,
bottom: 0,
right: imageTitlePadding
)
default:
self.contentEdgeInsets = UIEdgeInsets(
top: contentPadding.top,
left: contentPadding.left,
bottom: contentPadding.bottom,
right: contentPadding.right + imageTitlePadding
)
self.titleEdgeInsets = UIEdgeInsets(
top: 0,
left: imageTitlePadding,
bottom: 0,
right: -imageTitlePadding
)
}
}
}

View File

@ -0,0 +1,23 @@
//
// UIFont.swift
// Mastodon
//
// Created by BradGao on 2021/2/20.
//
import UIKit
extension UIFont {
private func withTraits(traits: UIFontDescriptor.SymbolicTraits) -> UIFont {
let descriptor = fontDescriptor.withSymbolicTraits(traits)
return UIFont(descriptor: descriptor!, size: 0) //size 0 means keep the size as it is
}
func bold() -> UIFont {
return withTraits(traits: .traitBold)
}
func italic() -> UIFont {
return withTraits(traits: .traitItalic)
}
}

View File

@ -0,0 +1,55 @@
//
// UIIamge.swift
// Mastodon
//
// Created by sxiaojian on 2021/1/28.
//
import UIKit
import CoreImage
import CoreImage.CIFilterBuiltins
extension UIImage {
static func placeholder(size: CGSize = CGSize(width: 1, height: 1), color: UIColor) -> UIImage {
let render = UIGraphicsImageRenderer(size: size)
return render.image { (context: UIGraphicsImageRendererContext) in
context.cgContext.setFillColor(color.cgColor)
context.fill(CGRect(origin: .zero, size: size))
}
}
}
// refs: https://www.hackingwithswift.com/example-code/media/how-to-read-the-average-color-of-a-uiimage-using-ciareaaverage
extension UIImage {
@available(iOS 14.0, *)
var dominantColor: UIColor? {
guard let inputImage = CIImage(image: self) else { return nil }
let filter = CIFilter.areaAverage()
filter.inputImage = inputImage
filter.extent = inputImage.extent
guard let outputImage = filter.outputImage else { return nil }
var bitmap = [UInt8](repeating: 0, count: 4)
let context = CIContext(options: [.workingColorSpace: kCFNull])
context.render(outputImage, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil)
return UIColor(red: CGFloat(bitmap[0]) / 255, green: CGFloat(bitmap[1]) / 255, blue: CGFloat(bitmap[2]) / 255, alpha: CGFloat(bitmap[3]) / 255)
}
}
extension UIImage {
func blur(radius: CGFloat) -> UIImage? {
guard let inputImage = CIImage(image: self) else { return nil }
let blurFilter = CIFilter.gaussianBlur()
blurFilter.inputImage = inputImage
blurFilter.radius = Float(radius)
guard let outputImage = blurFilter.outputImage else { return nil }
guard let cgImage = CIContext().createCGImage(outputImage, from: outputImage.extent) else { return nil }
let image = UIImage(cgImage: cgImage, scale: scale, orientation: imageOrientation)
return image
}
}

View File

@ -0,0 +1,26 @@
//
// UITapGestureRecognizer.swift
// Mastodon
//
// Created by sxiaojian on 2021/2/19.
//
import UIKit
extension UITapGestureRecognizer {
static var singleTapGestureRecognizer: UITapGestureRecognizer {
let tapGestureRecognizer = UITapGestureRecognizer()
tapGestureRecognizer.numberOfTapsRequired = 1
tapGestureRecognizer.numberOfTouchesRequired = 1
return tapGestureRecognizer
}
static var doubleTapGestureRecognizer: UITapGestureRecognizer {
let tapGestureRecognizer = UITapGestureRecognizer()
tapGestureRecognizer.numberOfTapsRequired = 2
tapGestureRecognizer.numberOfTouchesRequired = 1
return tapGestureRecognizer
}
}

View File

@ -0,0 +1,58 @@
//
// UIView.swift
// Mastodon
//
// Created by sxiaojian on 2021/2/4.
//
import UIKit
// MARK: - Convinience view creation method
extension UIView {
static var separatorLine: UIView {
let line = UIView()
line.backgroundColor = .separator
return line
}
static func separatorLineHeight(of view: UIView) -> CGFloat {
return 1.0 / view.traitCollection.displayScale
}
}
// MARK: - Convinience view appearance modification method
extension UIView {
@discardableResult
func applyCornerRadius(radius: CGFloat) -> Self {
layer.masksToBounds = true
layer.cornerRadius = radius
layer.cornerCurve = .continuous
return self
}
@discardableResult
func applyShadow(
color: UIColor,
alpha: Float,
x: CGFloat,
y: CGFloat,
blur: CGFloat,
spread: CGFloat = 0) -> Self
{
layer.masksToBounds = false
layer.shadowColor = color.cgColor
layer.shadowOpacity = alpha
layer.shadowOffset = CGSize(width: x, height: y)
layer.shadowRadius = blur / 2.0
if spread == 0 {
layer.shadowPath = nil
} else {
let dx = -spread
let rect = bounds.insetBy(dx: dx, dy: dx)
layer.shadowPath = UIBezierPath(rect: rect).cgPath
}
return self
}
}

View File

@ -0,0 +1,67 @@
//
// UIViewController.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021-1-27.
//
import UIKit
extension UIViewController {
/// Returns the top most view controller from given view controller's stack.
var topMost: UIViewController? {
// presented view controller
if let presentedViewController = presentedViewController {
return presentedViewController.topMost
}
// UITabBarController
if let tabBarController = self as? UITabBarController,
let selectedViewController = tabBarController.selectedViewController {
return selectedViewController.topMost
}
// UINavigationController
if let navigationController = self as? UINavigationController,
let visibleViewController = navigationController.visibleViewController {
return visibleViewController.topMost
}
// UIPageController
if let pageViewController = self as? UIPageViewController,
pageViewController.viewControllers?.count == 1 {
return pageViewController.viewControllers?.first?.topMost ?? self
}
// child view controller
for subview in self.view?.subviews ?? [] {
if let childViewController = subview.next as? UIViewController {
return childViewController.topMost
}
}
return self
}
}
extension UIViewController {
/// https://bluelemonbits.com/2018/08/26/inserting-cells-at-the-top-of-a-uitableview-with-no-scrolling/
static func topVisibleTableViewCellIndexPath(in tableView: UITableView, navigationBar: UINavigationBar) -> IndexPath? {
let navigationBarRectInTableView = tableView.convert(navigationBar.bounds, from: navigationBar)
let navigationBarMaxYPosition = CGPoint(x: 0, y: navigationBarRectInTableView.origin.y + navigationBarRectInTableView.size.height + 1) // +1pt for UIKit cell locate
let mostTopVisiableIndexPath = tableView.indexPathForRow(at: navigationBarMaxYPosition)
return mostTopVisiableIndexPath
}
static func tableViewCellOriginOffsetToWindowTop(in tableView: UITableView, at indexPath: IndexPath, navigationBar: UINavigationBar) -> CGFloat {
let rectForTopRow = tableView.rectForRow(at: indexPath)
let navigationBarRectInTableView = tableView.convert(navigationBar.bounds, from: navigationBar)
let navigationBarMaxYPosition = CGPoint(x: 0, y: navigationBarRectInTableView.origin.y + navigationBarRectInTableView.size.height) // without +1pt
let differenceBetweenTopRowAndNavigationBar = rectForTopRow.origin.y - navigationBarMaxYPosition.y
return differenceBetweenTopRowAndNavigationBar
}
}

View File

@ -12,6 +12,8 @@
// Deprecated typealiases
@available(*, deprecated, renamed: "ColorAsset.Color", message: "This typealias will be removed in SwiftGen 7.0")
internal typealias AssetColorTypeAlias = ColorAsset.Color
@available(*, deprecated, renamed: "ImageAsset.Image", message: "This typealias will be removed in SwiftGen 7.0")
internal typealias AssetImageTypeAlias = ImageAsset.Image
// swiftlint:disable superfluous_disable_command file_length implicit_return
@ -20,6 +22,56 @@ internal typealias AssetColorTypeAlias = ColorAsset.Color
// swiftlint:disable identifier_name line_length nesting type_body_length type_name
internal enum Asset {
internal static let accentColor = ColorAsset(name: "AccentColor")
internal enum Arrows {
internal static let arrowTriangle2Circlepath = ImageAsset(name: "Arrows/arrow.triangle.2.circlepath")
}
internal enum Asset {
internal static let mastodonTextLogo = ImageAsset(name: "Asset/mastodon.text.logo")
}
internal enum Colors {
internal enum Background {
internal static let onboardingBackground = ColorAsset(name: "Colors/Background/onboarding.background")
internal static let secondaryGroupedSystemBackground = ColorAsset(name: "Colors/Background/secondary.grouped.system.background")
internal static let secondarySystemBackground = ColorAsset(name: "Colors/Background/secondary.system.background")
internal static let systemBackground = ColorAsset(name: "Colors/Background/system.background")
internal static let systemGroupedBackground = ColorAsset(name: "Colors/Background/system.grouped.background")
internal static let tertiarySystemBackground = ColorAsset(name: "Colors/Background/tertiary.system.background")
}
internal enum Button {
internal static let actionToolbar = ColorAsset(name: "Colors/Button/action.toolbar")
internal static let disabled = ColorAsset(name: "Colors/Button/disabled")
internal static let highlight = ColorAsset(name: "Colors/Button/highlight")
}
internal enum Icon {
internal static let photo = ColorAsset(name: "Colors/Icon/photo")
internal static let plus = ColorAsset(name: "Colors/Icon/plus")
}
internal enum Label {
internal static let highlight = ColorAsset(name: "Colors/Label/highlight")
internal static let primary = ColorAsset(name: "Colors/Label/primary")
internal static let secondary = ColorAsset(name: "Colors/Label/secondary")
}
internal enum TextField {
internal static let highlight = ColorAsset(name: "Colors/TextField/highlight")
internal static let invalid = ColorAsset(name: "Colors/TextField/invalid")
internal static let valid = ColorAsset(name: "Colors/TextField/valid")
}
internal static let lightAlertYellow = ColorAsset(name: "Colors/lightAlertYellow")
internal static let lightBackground = ColorAsset(name: "Colors/lightBackground")
internal static let lightBrandBlue = ColorAsset(name: "Colors/lightBrandBlue")
internal static let lightDangerRed = ColorAsset(name: "Colors/lightDangerRed")
internal static let lightDarkGray = ColorAsset(name: "Colors/lightDarkGray")
internal static let lightDisabled = ColorAsset(name: "Colors/lightDisabled")
internal static let lightInactive = ColorAsset(name: "Colors/lightInactive")
internal static let lightSecondaryText = ColorAsset(name: "Colors/lightSecondaryText")
internal static let lightSuccessGreen = ColorAsset(name: "Colors/lightSuccessGreen")
internal static let lightWhite = ColorAsset(name: "Colors/lightWhite")
internal static let systemOrange = ColorAsset(name: "Colors/system.orange")
}
internal enum Welcome {
internal static let mastodonLogo = ImageAsset(name: "Welcome/mastodon.logo")
internal static let mastodonLogoLarge = ImageAsset(name: "Welcome/mastodon.logo.large")
}
}
// swiftlint:enable identifier_name line_length nesting type_body_length type_name
@ -61,10 +113,55 @@ internal extension ColorAsset.Color {
}
}
internal struct ImageAsset {
internal fileprivate(set) var name: String
#if os(macOS)
internal typealias Image = NSImage
#elseif os(iOS) || os(tvOS) || os(watchOS)
internal typealias Image = UIImage
#endif
internal var image: Image {
let bundle = BundleToken.bundle
#if os(iOS) || os(tvOS)
let image = Image(named: name, in: bundle, compatibleWith: nil)
#elseif os(macOS)
let name = NSImage.Name(self.name)
let image = (bundle == .main) ? NSImage(named: name) : bundle.image(forResource: name)
#elseif os(watchOS)
let image = Image(named: name)
#endif
guard let result = image else {
fatalError("Unable to load image asset named \(name).")
}
return result
}
}
internal extension ImageAsset.Image {
@available(macOS, deprecated,
message: "This initializer is unsafe on macOS, please use the ImageAsset.image property")
convenience init?(asset: ImageAsset) {
#if os(iOS) || os(tvOS)
let bundle = BundleToken.bundle
self.init(named: asset.name, in: bundle, compatibleWith: nil)
#elseif os(macOS)
self.init(named: NSImage.Name(asset.name))
#elseif os(watchOS)
self.init(named: asset.name)
#endif
}
}
// swiftlint:disable convenience_type
private final class BundleToken {
static let bundle: Bundle = {
Bundle(for: BundleToken.self)
#if SWIFT_PACKAGE
return Bundle.module
#else
return Bundle(for: BundleToken.self)
#endif
}()
}
// swiftlint:enable convenience_type

View File

@ -10,6 +10,204 @@ import Foundation
// swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length
// swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces
internal enum L10n {
internal enum Common {
internal enum Alerts {
internal enum ServerError {
/// Server Error
internal static let title = L10n.tr("Localizable", "Common.Alerts.ServerError.Title")
}
internal enum SignUpFailure {
/// Sign Up Failure
internal static let title = L10n.tr("Localizable", "Common.Alerts.SignUpFailure.Title")
}
}
internal enum Controls {
internal enum Actions {
/// Add
internal static let add = L10n.tr("Localizable", "Common.Controls.Actions.Add")
/// Back
internal static let back = L10n.tr("Localizable", "Common.Controls.Actions.Back")
/// Cancel
internal static let cancel = L10n.tr("Localizable", "Common.Controls.Actions.Cancel")
/// Confirm
internal static let confirm = L10n.tr("Localizable", "Common.Controls.Actions.Confirm")
/// Continue
internal static let `continue` = L10n.tr("Localizable", "Common.Controls.Actions.Continue")
/// Edit
internal static let edit = L10n.tr("Localizable", "Common.Controls.Actions.Edit")
/// OK
internal static let ok = L10n.tr("Localizable", "Common.Controls.Actions.Ok")
/// Open in Safari
internal static let openInSafari = L10n.tr("Localizable", "Common.Controls.Actions.OpenInSafari")
/// Preview
internal static let preview = L10n.tr("Localizable", "Common.Controls.Actions.Preview")
/// Remove
internal static let remove = L10n.tr("Localizable", "Common.Controls.Actions.Remove")
/// Save
internal static let save = L10n.tr("Localizable", "Common.Controls.Actions.Save")
/// Save photo
internal static let savePhoto = L10n.tr("Localizable", "Common.Controls.Actions.SavePhoto")
/// See More
internal static let seeMore = L10n.tr("Localizable", "Common.Controls.Actions.SeeMore")
/// Sign In
internal static let signIn = L10n.tr("Localizable", "Common.Controls.Actions.SignIn")
/// Sign Up
internal static let signUp = L10n.tr("Localizable", "Common.Controls.Actions.SignUp")
/// Take photo
internal static let takePhoto = L10n.tr("Localizable", "Common.Controls.Actions.TakePhoto")
}
internal enum Status {
/// Tap to reveal that may be sensitive
internal static let mediaContentWarning = L10n.tr("Localizable", "Common.Controls.Status.MediaContentWarning")
/// Show Post
internal static let showPost = L10n.tr("Localizable", "Common.Controls.Status.ShowPost")
/// content warning
internal static let statusContentWarning = L10n.tr("Localizable", "Common.Controls.Status.StatusContentWarning")
/// %@ boosted
internal static func userBoosted(_ p1: Any) -> String {
return L10n.tr("Localizable", "Common.Controls.Status.UserBoosted", String(describing: p1))
}
}
internal enum Timeline {
/// Load More
internal static let loadMore = L10n.tr("Localizable", "Common.Controls.Timeline.LoadMore")
}
}
internal enum Countable {
internal enum Photo {
/// photos
internal static let multiple = L10n.tr("Localizable", "Common.Countable.Photo.Multiple")
/// photo
internal static let single = L10n.tr("Localizable", "Common.Countable.Photo.Single")
}
}
}
internal enum Scene {
internal enum ConfirmEmail {
/// We just sent an email to %@,\ntap the link to confirm your account.
internal static func subtitle(_ p1: Any) -> String {
return L10n.tr("Localizable", "Scene.ConfirmEmail.Subtitle", String(describing: p1))
}
/// One last thing.
internal static let title = L10n.tr("Localizable", "Scene.ConfirmEmail.Title")
internal enum Button {
/// I never got an email
internal static let dontReceiveEmail = L10n.tr("Localizable", "Scene.ConfirmEmail.Button.DontReceiveEmail")
/// Open Email App
internal static let openEmailApp = L10n.tr("Localizable", "Scene.ConfirmEmail.Button.OpenEmailApp")
}
internal enum DontReceiveEmail {
/// Check if your email address is correct as well as your junk folder if you havent.
internal static let description = L10n.tr("Localizable", "Scene.ConfirmEmail.DontReceiveEmail.Description")
/// Resend Email
internal static let resendEmail = L10n.tr("Localizable", "Scene.ConfirmEmail.DontReceiveEmail.ResendEmail")
/// Check your email
internal static let title = L10n.tr("Localizable", "Scene.ConfirmEmail.DontReceiveEmail.Title")
}
internal enum OpenEmailApp {
/// We just sent you an email. Check your junk folder if you havent.
internal static let description = L10n.tr("Localizable", "Scene.ConfirmEmail.OpenEmailApp.Description")
/// Mail
internal static let mail = L10n.tr("Localizable", "Scene.ConfirmEmail.OpenEmailApp.Mail")
/// Open Email Client
internal static let openEmailClient = L10n.tr("Localizable", "Scene.ConfirmEmail.OpenEmailApp.OpenEmailClient")
/// Check your inbox.
internal static let title = L10n.tr("Localizable", "Scene.ConfirmEmail.OpenEmailApp.Title")
}
}
internal enum HomeTimeline {
/// Home
internal static let title = L10n.tr("Localizable", "Scene.HomeTimeline.Title")
}
internal enum PublicTimeline {
/// Public
internal static let title = L10n.tr("Localizable", "Scene.PublicTimeline.Title")
}
internal enum Register {
/// Regsiter request sent. Please check your email.
internal static let checkEmail = L10n.tr("Localizable", "Scene.Register.CheckEmail")
/// Success
internal static let success = L10n.tr("Localizable", "Scene.Register.Success")
/// Tell us about you.
internal static let title = L10n.tr("Localizable", "Scene.Register.Title")
internal enum Input {
internal enum DisplayName {
/// display name
internal static let placeholder = L10n.tr("Localizable", "Scene.Register.Input.DisplayName.Placeholder")
}
internal enum Email {
/// email
internal static let placeholder = L10n.tr("Localizable", "Scene.Register.Input.Email.Placeholder")
}
internal enum Invite {
/// Why do you want to join?
internal static let registrationUserInviteRequest = L10n.tr("Localizable", "Scene.Register.Input.Invite.RegistrationUserInviteRequest")
}
internal enum Password {
/// password
internal static let placeholder = L10n.tr("Localizable", "Scene.Register.Input.Password.Placeholder")
/// Your password needs at least:
internal static let prompt = L10n.tr("Localizable", "Scene.Register.Input.Password.Prompt")
/// Eight characters
internal static let promptEightCharacters = L10n.tr("Localizable", "Scene.Register.Input.Password.PromptEightCharacters")
}
internal enum Username {
/// This username is taken.
internal static let duplicatePrompt = L10n.tr("Localizable", "Scene.Register.Input.Username.DuplicatePrompt")
/// username
internal static let placeholder = L10n.tr("Localizable", "Scene.Register.Input.Username.Placeholder")
}
}
}
internal enum ServerPicker {
/// Pick a Server,\nany server.
internal static let title = L10n.tr("Localizable", "Scene.ServerPicker.Title")
internal enum Button {
/// See Less
internal static let seeless = L10n.tr("Localizable", "Scene.ServerPicker.Button.Seeless")
/// See More
internal static let seemore = L10n.tr("Localizable", "Scene.ServerPicker.Button.Seemore")
internal enum Category {
/// All
internal static let all = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.All")
}
}
internal enum Input {
/// Find a server or join your own...
internal static let placeholder = L10n.tr("Localizable", "Scene.ServerPicker.Input.Placeholder")
}
internal enum Label {
/// CATEGORY
internal static let category = L10n.tr("Localizable", "Scene.ServerPicker.Label.Category")
/// LANGUAGE
internal static let language = L10n.tr("Localizable", "Scene.ServerPicker.Label.Language")
/// USERS
internal static let users = L10n.tr("Localizable", "Scene.ServerPicker.Label.Users")
}
}
internal enum ServerRules {
/// By continuing, you're subject to the terms of service and privacy policy for %@.
internal static func prompt(_ p1: Any) -> String {
return L10n.tr("Localizable", "Scene.ServerRules.Prompt", String(describing: p1))
}
/// These rules are set by the admins of %@.
internal static func subtitle(_ p1: Any) -> String {
return L10n.tr("Localizable", "Scene.ServerRules.Subtitle", String(describing: p1))
}
/// Some ground rules.
internal static let title = L10n.tr("Localizable", "Scene.ServerRules.Title")
internal enum Button {
/// I Agree
internal static let confirm = L10n.tr("Localizable", "Scene.ServerRules.Button.Confirm")
}
}
internal enum Welcome {
/// Social networking\nback in your hands.
internal static let slogan = L10n.tr("Localizable", "Scene.Welcome.Slogan")
}
}
}
// swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length
// swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces
@ -25,6 +223,12 @@ extension L10n {
// swiftlint:disable convenience_type
private final class BundleToken {
static let bundle = Bundle(for: BundleToken.self)
static let bundle: Bundle = {
#if SWIFT_PACKAGE
return Bundle.module
#else
return Bundle(for: BundleToken.self)
#endif
}()
}
// swiftlint:enable convenience_type

View File

@ -17,7 +17,18 @@
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>1</string>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>sparrow</string>
<string>googlegmail</string>
<string>x-dispatch</string>
<string>readdle-spark</string>
<string>airmail</string>
<string>ms-outlook</string>
<string>ymail</string>
<string>fastmail</string>
</array>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
@ -42,7 +53,7 @@
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<string>Main</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIRequiredDeviceCapabilities</key>
@ -62,5 +73,7 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.org.joinmastodon.mastodon-temp</string>
</array>
</dict>
</plist>

View File

@ -0,0 +1,31 @@
{
"configurations" : [
{
"id" : "63D68260-F74D-4CA6-ADBC-B1263DD6BE55",
"name" : "Configuration 1",
"options" : {
}
}
],
"defaultOptions" : {
},
"testTargets" : [
{
"target" : {
"containerPath" : "container:Mastodon.xcodeproj",
"identifier" : "DB427DE725BAA00100D1B89D",
"name" : "MastodonTests"
}
},
{
"target" : {
"containerPath" : "container:Mastodon.xcodeproj",
"identifier" : "DB427DF225BAA00100D1B89D",
"name" : "MastodonUITests"
}
}
],
"version" : 1
}

View File

@ -0,0 +1,12 @@
//
// SplashPreference.swift
// Mastodon
//
// Created by Cirno MainasuK on 2020-2-4.
//
import UIKit
extension UserDefaults {
// TODO: splash scene
}

View File

@ -0,0 +1,123 @@
//
// AvatarConfigurableView.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021-2-4.
//
import UIKit
import AlamofireImage
import Kingfisher
protocol AvatarConfigurableView {
static var configurableAvatarImageSize: CGSize { get }
static var configurableAvatarImageCornerRadius: CGFloat { get }
var configurableAvatarImageView: UIImageView? { get }
var configurableAvatarButton: UIButton? { get }
func configure(with configuration: AvatarConfigurableViewConfiguration)
func avatarConfigurableView(_ avatarConfigurableView: AvatarConfigurableView, didFinishConfiguration configuration: AvatarConfigurableViewConfiguration)
}
extension AvatarConfigurableView {
public func configure(with configuration: AvatarConfigurableViewConfiguration) {
let placeholderImage: UIImage = {
let placeholderImage = configuration.placeholderImage ?? UIImage.placeholder(size: Self.configurableAvatarImageSize, color: .systemFill)
return placeholderImage.af.imageRoundedIntoCircle()
}()
// cancel previous task
configurableAvatarImageView?.af.cancelImageRequest()
configurableAvatarImageView?.kf.cancelDownloadTask()
configurableAvatarButton?.af.cancelImageRequest(for: .normal)
configurableAvatarButton?.kf.cancelImageDownloadTask()
// reset layer attributes
configurableAvatarImageView?.layer.masksToBounds = false
configurableAvatarImageView?.layer.cornerRadius = 0
configurableAvatarImageView?.layer.cornerCurve = .circular
configurableAvatarButton?.layer.masksToBounds = false
configurableAvatarButton?.layer.cornerRadius = 0
configurableAvatarButton?.layer.cornerCurve = .circular
defer {
avatarConfigurableView(self, didFinishConfiguration: configuration)
}
// set placeholder if no asset
guard let avatarImageURL = configuration.avatarImageURL else {
configurableAvatarImageView?.image = placeholderImage
configurableAvatarButton?.setImage(placeholderImage, for: .normal)
return
}
if let avatarImageView = configurableAvatarImageView {
// set avatar (GIF using Kingfisher)
switch avatarImageURL.pathExtension {
case "gif":
avatarImageView.kf.setImage(
with: avatarImageURL,
placeholder: placeholderImage,
options: [
.transition(.fade(0.2))
]
)
avatarImageView.layer.masksToBounds = true
avatarImageView.layer.cornerRadius = Self.configurableAvatarImageCornerRadius
avatarImageView.layer.cornerCurve = .circular
default:
let filter = ScaledToSizeWithRoundedCornersFilter(size: Self.configurableAvatarImageSize, radius: Self.configurableAvatarImageCornerRadius)
avatarImageView.af.setImage(
withURL: avatarImageURL,
placeholderImage: placeholderImage,
filter: filter,
imageTransition: .crossDissolve(0.3),
runImageTransitionIfCached: false,
completion: nil
)
}
}
if let avatarButton = configurableAvatarButton {
switch avatarImageURL.pathExtension {
case "gif":
avatarButton.kf.setImage(
with: avatarImageURL,
for: .normal,
placeholder: placeholderImage,
options: [
.transition(.fade(0.2))
]
)
avatarButton.layer.masksToBounds = true
avatarButton.layer.cornerRadius = Self.configurableAvatarImageCornerRadius
avatarButton.layer.cornerCurve = .continuous
default:
let filter = ScaledToSizeWithRoundedCornersFilter(size: Self.configurableAvatarImageSize, radius: Self.configurableAvatarImageCornerRadius)
avatarButton.af.setImage(
for: .normal,
url: avatarImageURL,
placeholderImage: placeholderImage,
filter: filter,
completion: nil
)
}
}
}
func avatarConfigurableView(_ avatarConfigurableView: AvatarConfigurableView, didFinishConfiguration configuration: AvatarConfigurableViewConfiguration) { }
}
struct AvatarConfigurableViewConfiguration {
let avatarImageURL: URL?
let placeholderImage: UIImage?
init(avatarImageURL: URL?, placeholderImage: UIImage? = nil) {
self.avatarImageURL = avatarImageURL
self.placeholderImage = placeholderImage
}
}

View File

@ -0,0 +1,13 @@
//
// ContentOffsetAdjustableTimelineViewControllerDelegate.swift
// Mastodon
//
// Created by sxiaojian on 2021/2/5.
//
import UIKit
protocol ContentOffsetAdjustableTimelineViewControllerDelegate: class {
func navigationBar() -> UINavigationBar?
}

View File

@ -0,0 +1,13 @@
//
// DisposeBagCollectable.swift
// Mastodon
//
// Created by sxiaojian on 2021/2/5.
//
import Foundation
import Combine
protocol DisposeBagCollectable: class {
var disposeBag: Set<AnyCancellable> { get set }
}

View File

@ -0,0 +1,49 @@
//
// LoadMoreConfigurableTableViewContainer.swift
// Mastodon
//
// Created by sxiaojian on 2021/2/3.
//
import UIKit
import GameplayKit
/// The tableView container driven by state machines with "LoadMore" logic
protocol LoadMoreConfigurableTableViewContainer: UIViewController {
associatedtype BottomLoaderTableViewCell: UITableViewCell
associatedtype LoadingState: GKState
var loadMoreConfigurableTableView: UITableView { get }
var loadMoreConfigurableStateMachine: GKStateMachine { get }
func handleScrollViewDidScroll(_ scrollView: UIScrollView)
}
extension LoadMoreConfigurableTableViewContainer {
func handleScrollViewDidScroll(_ scrollView: UIScrollView) {
guard scrollView === loadMoreConfigurableTableView else { return }
// check if current scroll position is the bottom of table
let contentOffsetY = loadMoreConfigurableTableView.contentOffset.y
let bottomVisiblePageContentOffsetY = loadMoreConfigurableTableView.contentSize.height - (1.5 * loadMoreConfigurableTableView.visibleSize.height)
guard contentOffsetY > bottomVisiblePageContentOffsetY else {
return
}
let cells = loadMoreConfigurableTableView.visibleCells.compactMap { $0 as? BottomLoaderTableViewCell }
guard let loaderTableViewCell = cells.first else { return }
if let tabBar = tabBarController?.tabBar, let window = view.window {
let loaderTableViewCellFrameInWindow = loadMoreConfigurableTableView.convert(loaderTableViewCell.frame, to: nil)
let windowHeight = window.frame.height
let loaderAppear = (loaderTableViewCellFrameInWindow.origin.y + 0.8 * loaderTableViewCell.frame.height) < (windowHeight - tabBar.frame.height)
if loaderAppear {
loadMoreConfigurableStateMachine.enter(LoadingState.self)
} else {
// do nothing
}
} else {
loadMoreConfigurableStateMachine.enter(LoadingState.self)
}
}
}

View File

@ -0,0 +1,19 @@
//
// ScrollViewContainer.swift
// Mastodon
//
// Created by sxiaojian on 2021/2/7.
//
import UIKit
protocol ScrollViewContainer: UIViewController {
var scrollView: UIScrollView { get }
func scrollToTop(animated: Bool)
}
extension ScrollViewContainer {
func scrollToTop(animated: Bool) {
scrollView.scrollRectToVisible(CGRect(origin: .zero, size: CGSize(width: 1, height: 1)), animated: animated)
}
}

View File

@ -0,0 +1,81 @@
//
// StatusProvider+TimelinePostTableViewCellDelegate.swift
// Mastodon
//
// Created by sxiaojian on 2021/2/8.
//
import os.log
import UIKit
import Combine
import CoreData
import CoreDataStack
import MastodonSDK
import ActiveLabel
// MARK: - ActionToolbarContainerDelegate
extension StatusTableViewCellDelegate where Self: StatusProvider {
func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) {
StatusProviderFacade.responseToStatusLikeAction(provider: self, cell: cell)
}
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton) {
guard let diffableDataSource = self.tableViewDiffableDataSource else { return }
item(for: cell, indexPath: nil)
.receive(on: DispatchQueue.main)
.sink { [weak self] item in
guard let _ = self else { return }
guard let item = item else { return }
switch item {
case .homeTimelineIndex(_, let attribute):
attribute.isStatusTextSensitive = false
case .toot(_, let attribute):
attribute.isStatusTextSensitive = false
default:
return
}
var snapshot = diffableDataSource.snapshot()
snapshot.reloadItems([item])
diffableDataSource.apply(snapshot)
}
.store(in: &cell.disposeBag)
}
}
extension StatusTableViewCellDelegate where Self: StatusProvider {
func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) {
}
func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) {
guard let diffableDataSource = self.tableViewDiffableDataSource else { return }
item(for: cell, indexPath: nil)
.receive(on: DispatchQueue.main)
.sink { [weak self] item in
guard let _ = self else { return }
guard let item = item else { return }
switch item {
case .homeTimelineIndex(_, let attribute):
attribute.isStatusSensitive = false
case .toot(_, let attribute):
attribute.isStatusSensitive = false
default:
return
}
var snapshot = diffableDataSource.snapshot()
snapshot.reloadItems([item])
UIView.animate(withDuration: 0.33) {
cell.statusView.statusMosaicImageView.blurVisualEffectView.effect = nil
cell.statusView.statusMosaicImageView.vibrancyVisualEffectView.alpha = 0.0
} completion: { _ in
diffableDataSource.apply(snapshot, animatingDifferences: false, completion: nil)
}
}
.store(in: &cell.disposeBag)
}
}

View File

@ -0,0 +1,19 @@
//
// StatusProvider.swift
// Mastodon
//
// Created by sxiaojian on 2021/2/5.
//
import UIKit
import Combine
import CoreDataStack
protocol StatusProvider: NeedsDependency & DisposeBagCollectable & UIViewController {
func toot() -> Future<Toot?, Never>
func toot(for cell: UITableViewCell, indexPath: IndexPath?) -> Future<Toot?, Never>
func toot(for cell: UICollectionViewCell) -> Future<Toot?, Never>
var tableViewDiffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? { get }
func item(for cell: UITableViewCell, indexPath: IndexPath?) -> Future<Item?, Never>
}

View File

@ -0,0 +1,129 @@
//
// StatusProviderFacade.swift
// Mastodon
//
// Created by sxiaojian on 2021/2/8.
//
import os.log
import UIKit
import Combine
import CoreData
import CoreDataStack
import MastodonSDK
import ActiveLabel
enum StatusProviderFacade {
}
extension StatusProviderFacade {
static func responseToStatusLikeAction(provider: StatusProvider) {
_responseToStatusLikeAction(
provider: provider,
toot: provider.toot()
)
}
static func responseToStatusLikeAction(provider: StatusProvider, cell: UITableViewCell) {
_responseToStatusLikeAction(
provider: provider,
toot: provider.toot(for: cell, indexPath: nil)
)
}
private static func _responseToStatusLikeAction(provider: StatusProvider, toot: Future<Toot?, Never>) {
// prepare authentication
guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else {
assertionFailure()
return
}
// prepare current user infos
guard let _currentMastodonUser = provider.context.authenticationService.activeMastodonAuthentication.value?.user else {
assertionFailure()
return
}
let mastodonUserID = activeMastodonAuthenticationBox.userID
assert(_currentMastodonUser.id == mastodonUserID)
let mastodonUserObjectID = _currentMastodonUser.objectID
guard let context = provider.context else { return }
// haptic feedback generator
let generator = UIImpactFeedbackGenerator(style: .light)
let responseFeedbackGenerator = UIImpactFeedbackGenerator(style: .medium)
toot
.compactMap { toot -> (NSManagedObjectID, Mastodon.API.Favorites.FavoriteKind)? in
guard let toot = toot else { return nil }
let favoriteKind: Mastodon.API.Favorites.FavoriteKind = {
let targetToot = (toot.reblog ?? toot)
let isLiked = targetToot.favouritedBy.flatMap { $0.contains(where: { $0.id == mastodonUserID }) } ?? false
return isLiked ? .destroy : .create
}()
return (toot.objectID, favoriteKind)
}
.map { tootObjectID, favoriteKind -> AnyPublisher<(Toot.ID, Mastodon.API.Favorites.FavoriteKind), Error> in
return context.apiService.like(
tootObjectID: tootObjectID,
mastodonUserObjectID: mastodonUserObjectID,
favoriteKind: favoriteKind
)
.map { tootID in (tootID, favoriteKind) }
.eraseToAnyPublisher()
}
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
.switchToLatest()
.receive(on: DispatchQueue.main)
.handleEvents { _ in
generator.prepare()
responseFeedbackGenerator.prepare()
} receiveOutput: { _, favoriteKind in
generator.impactOccurred()
os_log("%{public}s[%{public}ld], %{public}s: [Like] update local toot like status to: %s", ((#file as NSString).lastPathComponent), #line, #function, favoriteKind == .create ? "like" : "unlike")
} receiveCompletion: { completion in
switch completion {
case .failure:
// TODO: handle error
break
case .finished:
break
}
}
.map { tootID, favoriteKind in
return context.apiService.like(
statusID: tootID,
favoriteKind: favoriteKind,
mastodonAuthenticationBox: activeMastodonAuthenticationBox
)
}
.switchToLatest()
.receive(on: DispatchQueue.main)
.sink { [weak provider] completion in
guard let provider = provider else { return }
if provider.view.window != nil {
responseFeedbackGenerator.impactOccurred()
}
switch completion {
case .failure(let error):
os_log("%{public}s[%{public}ld], %{public}s: [Like] remote like request fail: %{public}s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
case .finished:
os_log("%{public}s[%{public}ld], %{public}s: [Like] remote like request success", ((#file as NSString).lastPathComponent), #line, #function)
}
} receiveValue: { response in
// do nothing
}
.store(in: &provider.disposeBag)
}
}
extension StatusProviderFacade {
enum Target {
case toot
case reblog
}
}

View File

@ -1,91 +1,109 @@
{
"images" : [
{
"filename" : "notification-icon@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "20x20"
},
{
"filename" : "notification-icon@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "20x20"
},
{
"filename" : "icon-small@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "icon-small@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "29x29"
},
{
"filename" : "icon-40@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "40x40"
},
{
"filename" : "icon-40@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "40x40"
},
{
"filename" : "icon-60@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "60x60"
},
{
"filename" : "icon-60@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "60x60"
},
{
"filename" : "notification-icon~ipad.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "20x20"
},
{
"filename" : "notification-icon~ipad@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "20x20"
},
{
"filename" : "icon-small.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "29x29"
},
{
"filename" : "icon-small@2x-1.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "icon-40.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "40x40"
},
{
"filename" : "icon-40@2x-1.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "40x40"
},
{
"filename" : "icon-76.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "76x76"
},
{
"filename" : "icon-76@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "76x76"
},
{
"filename" : "icon-83.5@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "83.5x83.5"
},
{
"filename" : "ios-marketing.png",
"idiom" : "ios-marketing",
"scale" : "1x",
"size" : "1024x1024"

Binary file not shown.

After

Width:  |  Height:  |  Size: 993 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 659 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 993 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 993 B

View File

@ -0,0 +1,9 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"provides-namespace" : true
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "arrow.triangle.2.circlepath.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,193 @@
%PDF-1.7
1 0 obj
<< >>
endobj
2 0 obj
<< /Length 3 0 R >>
stream
/DeviceRGB CS
/DeviceRGB cs
q
1.000000 0.000000 -0.000000 1.000000 4.000000 10.752930 cm
0.000000 0.000000 0.000000 scn
15.009519 2.109471 m
15.085540 1.562444 15.590621 1.180617 16.137648 1.256639 c
16.684677 1.332660 17.066502 1.837741 16.990480 2.384768 c
15.009519 2.109471 l
h
-0.423099 4.631682 m
-0.635487 4.121869 -0.394376 3.536408 0.115438 3.324021 c
0.625251 3.111633 1.210711 3.352744 1.423099 3.862558 c
-0.423099 4.631682 l
h
1.000000 8.247120 m
1.000000 8.799404 0.552285 9.247120 0.000000 9.247120 c
-0.552285 9.247120 -1.000000 8.799404 -1.000000 8.247120 c
1.000000 8.247120 l
h
0.000000 4.247120 m
-1.000000 4.247120 l
-1.000000 3.694835 -0.552285 3.247120 0.000000 3.247120 c
0.000000 4.247120 l
h
4.000000 3.247120 m
4.552285 3.247120 5.000000 3.694835 5.000000 4.247120 c
5.000000 4.799405 4.552285 5.247120 4.000000 5.247120 c
4.000000 3.247120 l
h
16.990480 2.384768 m
16.715729 4.361807 15.798570 6.193669 14.380284 7.598174 c
12.972991 6.177073 l
14.079566 5.081251 14.795152 3.651996 15.009519 2.109471 c
16.990480 2.384768 l
h
14.380284 7.598174 m
12.961998 9.002679 11.121269 9.901910 9.141643 10.157345 c
8.885699 8.173789 l
10.430243 7.974494 11.866417 7.272897 12.972991 6.177073 c
14.380284 7.598174 l
h
9.141643 10.157345 m
7.162015 10.412781 5.153316 10.010252 3.424967 9.011765 c
4.425436 7.279984 l
5.773929 8.059025 7.341156 8.373085 8.885699 8.173789 c
9.141643 10.157345 l
h
3.424967 9.011765 m
1.696617 8.013276 0.344502 6.474223 -0.423099 4.631682 c
1.423099 3.862558 l
2.021996 5.300145 3.076944 6.500945 4.425436 7.279984 c
3.424967 9.011765 l
h
-1.000000 8.247120 m
-1.000000 4.247120 l
1.000000 4.247120 l
1.000000 8.247120 l
-1.000000 8.247120 l
h
0.000000 3.247120 m
4.000000 3.247120 l
4.000000 5.247120 l
0.000000 5.247120 l
0.000000 3.247120 l
h
f
n
Q
q
1.000000 0.000000 -0.000000 1.000000 4.000000 1.767822 cm
0.000000 0.000000 0.000000 scn
0.990481 9.369826 m
0.914460 9.916854 0.409379 10.298680 -0.137649 10.222659 c
-0.684676 10.146638 -1.066502 9.641557 -0.990481 9.094529 c
0.990481 9.369826 l
h
16.423100 6.847616 m
16.635487 7.357429 16.394375 7.942889 15.884562 8.155277 c
15.374748 8.367664 14.789289 8.126554 14.576900 7.616740 c
16.423100 6.847616 l
h
15.000000 3.232178 m
15.000000 2.679893 15.447715 2.232178 16.000000 2.232178 c
16.552284 2.232178 17.000000 2.679893 17.000000 3.232178 c
15.000000 3.232178 l
h
16.000000 7.232178 m
17.000000 7.232178 l
17.000000 7.784462 16.552284 8.232178 16.000000 8.232178 c
16.000000 7.232178 l
h
12.000000 8.232178 m
11.447715 8.232178 11.000000 7.784462 11.000000 7.232178 c
11.000000 6.679893 11.447715 6.232178 12.000000 6.232178 c
12.000000 8.232178 l
h
-0.990481 9.094529 m
-0.715729 7.117491 0.201429 5.285628 1.619715 3.881123 c
3.027008 5.302223 l
1.920433 6.398046 1.204848 7.827302 0.990481 9.369826 c
-0.990481 9.094529 l
h
1.619715 3.881123 m
3.038001 2.476617 4.878731 1.577388 6.858358 1.321952 c
7.114300 3.305508 l
5.569757 3.504804 4.133582 4.206400 3.027008 5.302223 c
1.619715 3.881123 l
h
6.858358 1.321952 m
8.837985 1.066517 10.846684 1.469046 12.575033 2.467534 c
11.574564 4.199314 l
10.226071 3.420273 8.658844 3.106212 7.114300 3.305508 c
6.858358 1.321952 l
h
12.575033 2.467534 m
14.303383 3.466022 15.655499 5.005074 16.423100 6.847616 c
14.576900 7.616740 l
13.978004 6.179152 12.923057 4.978354 11.574564 4.199314 c
12.575033 2.467534 l
h
17.000000 3.232178 m
17.000000 7.232178 l
15.000000 7.232178 l
15.000000 3.232178 l
17.000000 3.232178 l
h
16.000000 8.232178 m
12.000000 8.232178 l
12.000000 6.232178 l
16.000000 6.232178 l
16.000000 8.232178 l
h
f
n
Q
endstream
endobj
3 0 obj
3597
endobj
4 0 obj
<< /Annots []
/Type /Page
/MediaBox [ 0.000000 0.000000 24.000000 24.000000 ]
/Resources 1 0 R
/Contents 2 0 R
/Parent 5 0 R
>>
endobj
5 0 obj
<< /Kids [ 4 0 R ]
/Count 1
/Type /Pages
>>
endobj
6 0 obj
<< /Type /Catalog
/Pages 5 0 R
>>
endobj
xref
0 7
0000000000 65535 f
0000000010 00000 n
0000000034 00000 n
0000003687 00000 n
0000003710 00000 n
0000003883 00000 n
0000003957 00000 n
trailer
<< /ID [ (some) (id) ]
/Root 6 0 R
/Size 7
>>
startxref
4016
%%EOF

View File

@ -0,0 +1,9 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"provides-namespace" : true
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "mastodon.title.logo.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,229 @@
%PDF-1.7
1 0 obj
<< >>
endobj
2 0 obj
<< /Length 3 0 R >>
stream
/DeviceRGB CS
/DeviceRGB cs
q
1.000000 0.000000 -0.000000 1.000000 2.000000 0.579529 cm
0.121569 0.137255 0.168627 scn
16.348507 7.298912 m
16.348507 0.110882 l
13.500799 0.110882 l
13.500799 7.087682 l
13.500799 8.558509 12.881892 9.304815 11.644079 9.304815 c
10.275755 9.304815 9.589746 8.419246 9.589746 6.668335 c
9.589746 2.849669 l
6.758763 2.849669 l
6.758763 6.668335 l
6.758763 8.419246 6.072753 9.304815 4.704429 9.304815 c
3.466616 9.304815 2.847710 8.558509 2.847710 7.087682 c
2.847710 0.110882 l
0.000000 0.110882 l
0.000000 7.298912 l
0.000000 8.767988 0.374029 9.935391 1.125391 10.799177 c
1.900289 11.662767 2.915007 12.105455 4.174410 12.105455 c
5.631816 12.105455 6.735424 11.545483 7.464808 10.425148 c
8.174352 9.235961 l
8.883701 10.425148 l
9.613279 11.545483 10.716693 12.105455 12.174294 12.105455 c
13.433697 12.105455 14.448222 11.662767 15.223120 10.799177 c
15.974483 9.935391 16.348507 8.767988 16.348507 7.298912 c
h
26.158895 3.725689 m
26.746487 4.346540 27.029490 5.128441 27.029490 6.071388 c
27.029490 7.014336 26.746487 7.796235 26.158895 8.394135 c
25.593088 9.015182 24.874598 9.313937 24.004005 9.313937 c
23.133219 9.313937 22.414919 9.015182 21.849112 8.394135 c
21.283110 7.796235 21.000111 7.014336 21.000111 6.071388 c
21.000111 5.128441 21.283110 4.346540 21.849112 3.725689 c
22.414919 3.127789 23.133219 2.828838 24.004005 2.828838 c
24.874598 2.828838 25.593088 3.127789 26.158895 3.725689 c
h
27.029490 11.820879 m
29.837523 11.820879 l
29.837523 0.321898 l
27.029490 0.321898 l
27.029490 1.678941 l
26.180681 0.551994 25.005110 -0.000004 23.481573 -0.000004 c
22.023193 -0.000004 20.782465 0.574944 19.737598 1.747989 c
18.714710 2.920838 18.192272 4.369687 18.192272 6.071388 c
18.192272 7.750138 18.714710 9.199181 19.737598 10.372030 c
20.782465 11.544880 22.023193 12.142780 23.481573 12.142780 c
25.005110 12.142780 26.180681 11.590782 27.029490 10.464029 c
27.029490 11.820879 l
h
39.284966 6.278339 m
40.111988 5.657488 40.525707 4.783587 40.503922 3.679787 c
40.503922 2.506742 40.090210 1.586940 39.241402 0.942943 c
38.392399 0.321896 37.369507 -0.000004 36.128777 -0.000004 c
33.886749 -0.000004 32.363014 0.919992 31.557581 2.736839 c
33.995674 4.185493 l
34.322048 3.196836 35.040340 2.690742 36.128777 2.690742 c
37.129879 2.690742 37.630730 3.012838 37.630730 3.679787 c
37.630730 4.162736 36.977592 4.599589 35.649918 4.944442 c
35.149075 5.082539 34.735561 5.220440 34.409187 5.335586 c
33.952106 5.519390 33.560379 5.726535 33.233810 5.979388 c
32.428375 6.600240 32.014854 7.428431 32.014854 8.486135 c
32.014854 9.613082 32.406590 10.509933 33.190239 11.153931 c
33.995674 11.820879 34.974995 12.142780 36.150566 12.142780 c
38.022457 12.142780 39.393890 11.337929 40.286072 9.705081 c
37.891945 8.325281 l
37.543591 9.106986 36.956001 9.497936 36.150566 9.497936 c
35.301563 9.497936 34.888054 9.176035 34.888054 8.555183 c
34.888054 8.072233 35.540989 7.635381 36.868858 7.290334 c
37.891941 7.060431 38.697178 6.715386 39.284966 6.278339 c
h
48.209846 8.969084 m
45.750168 8.969084 l
45.750168 4.185493 l
45.750168 3.610543 45.968018 3.265691 46.381531 3.104837 c
46.686317 2.989692 47.295685 2.966742 48.209846 3.012838 c
48.209846 0.321898 l
46.316364 0.091995 44.944931 0.275993 44.139496 0.897040 c
43.334255 1.494941 42.942337 2.598742 42.942337 4.185493 c
42.942337 8.969084 l
41.048660 8.969084 l
41.048660 11.820879 l
42.942337 11.820879 l
42.942337 14.143626 l
45.750168 15.040477 l
45.750168 11.820879 l
48.209846 11.820879 l
48.209846 8.969084 l
h
57.156685 3.794641 m
57.722687 4.392735 58.005493 5.151684 58.005493 6.071486 c
58.005493 6.991287 57.722687 7.750236 57.156685 8.348136 c
56.590683 8.946231 55.894169 9.244986 55.045166 9.244986 c
54.196358 9.244986 53.499847 8.946231 52.933846 8.348136 c
52.389629 7.727284 52.106628 6.968336 52.106628 6.071486 c
52.106628 5.174440 52.389629 4.415492 52.933846 3.794641 c
53.499847 3.196740 54.196358 2.897789 55.045166 2.897789 c
55.894169 2.897789 56.590683 3.196740 57.156685 3.794641 c
h
50.953033 1.747891 m
49.843010 2.920741 49.298790 4.346638 49.298790 6.071486 c
49.298790 7.773381 49.843010 9.199083 50.953033 10.371933 c
52.063057 11.544782 53.434490 12.142683 55.045166 12.142683 c
56.656036 12.142683 58.027279 11.544782 59.137497 10.371933 c
60.247715 9.199083 60.813522 7.750236 60.813522 6.071486 c
60.813522 4.369590 60.247715 2.920741 59.137497 1.747891 c
58.027279 0.574847 56.677818 0.000093 55.045166 0.000093 c
53.412708 0.000093 52.063057 0.574847 50.953033 1.747891 c
h
70.195557 3.725689 m
70.761559 4.346540 71.044373 5.128441 71.044373 6.071388 c
71.044373 7.014336 70.761559 7.796235 70.195557 8.394135 c
69.629745 9.015182 68.911255 9.313937 68.040665 9.313937 c
67.169876 9.313937 66.451584 9.015182 65.863991 8.394135 c
65.298180 7.796235 65.014984 7.014336 65.014984 6.071388 c
65.014984 5.128441 65.298180 4.346540 65.863991 3.725689 c
66.451584 3.127789 67.191658 2.828838 68.040665 2.828838 c
68.911255 2.828838 69.629745 3.127789 70.195557 3.725689 c
h
71.044373 16.420471 m
73.852386 16.420471 l
73.852386 0.321898 l
71.044373 0.321898 l
71.044373 1.678941 l
70.217346 0.551994 69.041771 -0.000004 67.518234 -0.000004 c
66.059853 -0.000004 64.797539 0.574944 63.752670 1.747989 c
62.729588 2.920838 62.207153 4.369687 62.207153 6.071388 c
62.207153 7.750138 62.729588 9.199181 63.752670 10.372030 c
64.797539 11.544880 66.059853 12.142780 67.518234 12.142780 c
69.041771 12.142780 70.217346 11.590782 71.044373 10.464029 c
71.044373 16.420471 l
h
83.713470 3.794641 m
84.279282 4.392735 84.562279 5.151684 84.562279 6.071486 c
84.562279 6.991287 84.279282 7.750236 83.713470 8.348136 c
83.147469 8.946231 82.450958 9.244986 81.601952 9.244986 c
80.753143 9.244986 80.056442 8.946231 79.490631 8.348136 c
78.946220 7.727284 78.663406 6.968336 78.663406 6.071486 c
78.663406 5.174440 78.946220 4.415492 79.490631 3.794641 c
80.056442 3.196740 80.753143 2.897789 81.601952 2.897789 c
82.450958 2.897789 83.147469 3.196740 83.713470 3.794641 c
h
77.509811 1.747891 m
76.399590 2.920741 75.855576 4.346638 75.855576 6.071486 c
75.855576 7.773381 76.399590 9.199083 77.509811 10.371933 c
78.620033 11.544782 79.991280 12.142683 81.601952 12.142683 c
83.212822 12.142683 84.584061 11.544782 85.694283 10.371933 c
86.804504 9.199083 87.370308 7.750236 87.370308 6.071486 c
87.370308 4.369590 86.804504 2.920741 85.694283 1.747891 c
84.584061 0.574847 83.234604 0.000093 81.601952 0.000093 c
79.969490 0.000093 78.620033 0.574847 77.509811 1.747891 c
h
99.516785 7.382295 m
99.516785 0.322052 l
96.708755 0.322052 l
96.708755 7.014297 l
96.708755 7.773245 96.512894 8.348194 96.121162 8.785046 c
95.751022 9.175996 95.228592 9.383141 94.553864 9.383141 c
92.964783 9.383141 92.159538 8.440193 92.159538 6.531347 c
92.159538 0.322052 l
89.351509 0.322052 l
89.351509 11.820840 l
92.159538 11.820840 l
92.159538 10.533039 l
92.834267 11.613889 93.900719 12.142740 95.402863 12.142740 c
96.600021 12.142740 97.579544 11.728840 98.341408 10.877892 c
99.124863 10.026944 99.516785 8.877046 99.516785 7.382295 c
h
f
n
Q
endstream
endobj
3 0 obj
7081
endobj
4 0 obj
<< /Annots []
/Type /Page
/MediaBox [ 0.000000 0.000000 103.000000 17.000000 ]
/Resources 1 0 R
/Contents 2 0 R
/Parent 5 0 R
>>
endobj
5 0 obj
<< /Kids [ 4 0 R ]
/Count 1
/Type /Pages
>>
endobj
6 0 obj
<< /Type /Catalog
/Pages 5 0 R
>>
endobj
xref
0 7
0000000000 65535 f
0000000010 00000 n
0000000034 00000 n
0000007171 00000 n
0000007194 00000 n
0000007368 00000 n
0000007442 00000 n
trailer
<< /ID [ (some) (id) ]
/Root 6 0 R
/Size 7
>>
startxref
7501
%%EOF

View File

@ -0,0 +1,9 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"provides-namespace" : true
}
}

Some files were not shown because too many files have changed in this diff Show More