Merge branch 'release/0.1.0' into main
|
@ -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
|
|
@ -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>
|
|
@ -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>
|
||||
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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)]
|
||||
}
|
||||
}
|
|
@ -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)]
|
||||
}
|
||||
}
|
|
@ -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)]
|
||||
}
|
||||
}
|
|
@ -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)]
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
])
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
])
|
||||
}
|
||||
|
||||
}
|
|
@ -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)]
|
||||
}
|
||||
}
|
|
@ -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)]
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
|
||||
}
|
|
@ -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>
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
//
|
||||
// NetworkUpdatable.swift
|
||||
// CoreDataStack
|
||||
//
|
||||
// Created by Cirno MainasuK on 2020-9-4.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public protocol NetworkUpdatable {
|
||||
var networkDate: Date { get }
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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>
|
|
@ -0,0 +1,8 @@
|
|||
# Localization
|
||||
|
||||
Mastodon localization template file
|
||||
|
||||
|
||||
## How to contribute?
|
||||
|
||||
TBD
|
|
@ -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"]),
|
||||
]
|
||||
)
|
|
@ -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
|
||||
```
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
|
@ -0,0 +1,7 @@
|
|||
import XCTest
|
||||
|
||||
import StringsConvertorTests
|
||||
|
||||
var tests = [XCTestCaseEntry]()
|
||||
tests += StringsConvertorTests.allTests()
|
||||
XCTMain(tests)
|
|
@ -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),
|
||||
]
|
||||
}
|
|
@ -3,7 +3,7 @@ import XCTest
|
|||
#if !canImport(ObjectiveC)
|
||||
public func allTests() -> [XCTestCaseEntry] {
|
||||
return [
|
||||
testCase(MastodonSDKTests.allTests),
|
||||
testCase(StringsConvertorTests.allTests),
|
||||
]
|
||||
}
|
||||
#endif
|
|
@ -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
|
|
@ -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 haven’t.",
|
||||
"resend_email": "Resend Email"
|
||||
},
|
||||
"open_email_app": {
|
||||
"title": "Check your inbox.",
|
||||
"description": "We just sent you an email. Check your junk folder if you haven’t.",
|
||||
"mail": "Mail",
|
||||
"open_email_client": "Open Email Client"
|
||||
}
|
||||
},
|
||||
"home_timeline": {
|
||||
"title": "Home"
|
||||
},
|
||||
"public_timeline": {
|
||||
"title": "Public"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"NSCameraUsageDescription": "Used to take photo for toot",
|
||||
"NSPhotoLibraryAddUsageDescription": "Used to save photo into the Photo Library"
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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 = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}()
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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))"
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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 haven’t.
|
||||
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 haven’t.
|
||||
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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
//
|
||||
// SplashPreference.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by Cirno MainasuK on 2020-2-4.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UserDefaults {
|
||||
// TODO: splash scene
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
//
|
||||
// ContentOffsetAdjustableTimelineViewControllerDelegate.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/2/5.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
protocol ContentOffsetAdjustableTimelineViewControllerDelegate: class {
|
||||
func navigationBar() -> UINavigationBar?
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
After Width: | Height: | Size: 993 B |
After Width: | Height: | Size: 2.5 KiB |
After Width: | Height: | Size: 2.5 KiB |
After Width: | Height: | Size: 4.1 KiB |
After Width: | Height: | Size: 4.1 KiB |
After Width: | Height: | Size: 6.8 KiB |
After Width: | Height: | Size: 2.3 KiB |
After Width: | Height: | Size: 5.5 KiB |
After Width: | Height: | Size: 6.3 KiB |
After Width: | Height: | Size: 659 B |
After Width: | Height: | Size: 1.5 KiB |
After Width: | Height: | Size: 1.5 KiB |
After Width: | Height: | Size: 2.8 KiB |
After Width: | Height: | Size: 36 KiB |
After Width: | Height: | Size: 993 B |
After Width: | Height: | Size: 1.6 KiB |
After Width: | Height: | Size: 444 B |
After Width: | Height: | Size: 993 B |
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"provides-namespace" : true
|
||||
}
|
||||
}
|
12
Mastodon/Resources/Assets.xcassets/Arrows/arrow.triangle.2.circlepath.imageset/Contents.json
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "arrow.triangle.2.circlepath.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"provides-namespace" : true
|
||||
}
|
||||
}
|
12
Mastodon/Resources/Assets.xcassets/Asset/mastodon.text.logo.imageset/Contents.json
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "mastodon.title.logo.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
229
Mastodon/Resources/Assets.xcassets/Asset/mastodon.text.logo.imageset/mastodon.title.logo.pdf
vendored
Normal 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
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"provides-namespace" : true
|
||||
}
|
||||
}
|