Merge pull request #327 from mastodon/feature/v2-timeline

Update Timeline UI
This commit is contained in:
CMK 2022-02-11 23:01:11 +08:00 committed by GitHub
commit c4c297a3de
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
837 changed files with 34484 additions and 29340 deletions

View File

@ -19,8 +19,8 @@ jobs:
steps:
- name: checkout
uses: actions/checkout@v2
- name: force Xcode 13.1
run: sudo xcode-select -switch /Applications/Xcode_13.1.app
- name: force Xcode 13.2.1
run: sudo xcode-select -switch /Applications/Xcode_13.2.1.app
- name: setup
run: exec ./.github/scripts/setup.sh
- name: build

View File

@ -11,6 +11,10 @@ import CryptoKit
import KeychainAccess
import Keys
enum AppName {
public static let groupID = "group.org.joinmastodon.app"
}
public final class AppSecret {
public static let keychain = Keychain(service: "org.joinmastodon.app.keychain", accessGroup: AppName.groupID)

View File

@ -17,6 +17,6 @@
<key>CFBundleShortVersionString</key>
<string>1.3.0</string>
<key>CFBundleVersion</key>
<string>90</string>
<string>96</string>
</dict>
</plist>

View File

@ -6,6 +6,7 @@
//
import UIKit
import MastodonCommon
extension UserDefaults {
public static let shared = UserDefaults(suiteName: AppName.groupID)!

View File

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

View File

@ -1,126 +0,0 @@
//
// 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 relationship
@NSManaged public private(set) var status: Status?
}
public extension Attachment {
override func awakeFromInsert() {
super.awakeFromInsert()
setPrimitiveValue(Date(), forKey: #keyPath(Attachment.createdAt))
}
@discardableResult
static func insert(
into context: NSManagedObjectContext,
property: Property
) -> Attachment {
let attachment: Attachment = context.insertObject()
attachment.domain = property.domain
attachment.index = property.index
attachment.id = property.id
attachment.typeRaw = property.typeRaw
attachment.url = property.url
attachment.previewURL = property.previewURL
attachment.remoteURL = property.remoteURL
attachment.metaData = property.metaData
attachment.textURL = property.textURL
attachment.descriptionString = property.descriptionString
attachment.blurhash = property.blurhash
attachment.updatedAt = property.networkDate
return attachment
}
func didUpdate(at networkDate: Date) {
self.updatedAt = networkDate
}
}
public extension Attachment {
struct Property {
public let domain: String
public let index: NSNumber
public let id: ID
public let typeRaw: String
public let url: String
public let previewURL: String?
public let remoteURL: String?
public let metaData: Data?
public let textURL: String?
public let descriptionString: String?
public let blurhash: String?
public let networkDate: Date
public init(
domain: String,
index: Int,
id: Attachment.ID,
typeRaw: String,
url: String,
previewURL: String?,
remoteURL: String?,
metaData: Data?,
textURL: String?,
descriptionString: String?,
blurhash: String?,
networkDate: Date
) {
self.domain = domain
self.index = NSNumber(value: index)
self.id = id
self.typeRaw = typeRaw
self.url = url
self.previewURL = previewURL
self.remoteURL = remoteURL
self.metaData = metaData
self.textURL = textURL
self.descriptionString = descriptionString
self.blurhash = blurhash
self.networkDate = networkDate
}
}
}
extension Attachment: Managed {
public static var defaultSortDescriptors: [NSSortDescriptor] {
return [NSSortDescriptor(keyPath: \Attachment.createdAt, ascending: false)]
}
}

View File

@ -1,102 +0,0 @@
//
// 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 status: Status
}
extension HomeTimelineIndex {
@discardableResult
public static func insert(
into context: NSManagedObjectContext,
property: Property,
status: Status
) -> HomeTimelineIndex {
let index: HomeTimelineIndex = context.insertObject()
index.identifier = property.identifier
index.domain = property.domain
index.userID = property.userID
index.createdAt = status.createdAt
index.status = status
return index
}
public func update(hasMore: Bool) {
if self.hasMore != hasMore {
self.hasMore = hasMore
}
}
// internal method for status 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 {
static func predicate(domain: String) -> NSPredicate {
return NSPredicate(format: "%K == %@", #keyPath(HomeTimelineIndex.domain), domain)
}
static func predicate(userID: MastodonUser.ID) -> NSPredicate {
return NSPredicate(format: "%K == %@", #keyPath(HomeTimelineIndex.userID), userID)
}
public static func predicate(domain: String, userID: MastodonUser.ID) -> NSPredicate {
return NSCompoundPredicate(andPredicateWithSubpredicates: [
predicate(domain: domain),
predicate(userID: userID)
])
}
public static func notDeleted() -> NSPredicate {
return NSPredicate(format: "%K == nil", #keyPath(HomeTimelineIndex.deletedAt))
}
}

View File

@ -1,407 +0,0 @@
//
// 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 header: String
@NSManaged public private(set) var headerStatic: String?
@NSManaged public private(set) var note: String?
@NSManaged public private(set) var url: String?
@NSManaged public private(set) var emojisData: Data?
@NSManaged public private(set) var fieldsData: Data?
@NSManaged public private(set) var statusesCount: NSNumber
@NSManaged public private(set) var followingCount: NSNumber
@NSManaged public private(set) var followersCount: NSNumber
@NSManaged public private(set) var locked: Bool
@NSManaged public private(set) var bot: Bool
@NSManaged public private(set) var suspended: Bool
@NSManaged public private(set) var createdAt: Date
@NSManaged public private(set) var updatedAt: Date
// one-to-one relationship
@NSManaged public private(set) var pinnedStatus: Status?
@NSManaged public private(set) var mastodonAuthentication: MastodonAuthentication?
// one-to-many relationship
@NSManaged public private(set) var statuses: Set<Status>?
@NSManaged public private(set) var notifications: Set<MastodonNotification>?
@NSManaged public private(set) var searchHistories: Set<SearchHistory>
// many-to-many relationship
@NSManaged public private(set) var favourite: Set<Status>?
@NSManaged public private(set) var reblogged: Set<Status>?
@NSManaged public private(set) var muted: Set<Status>?
@NSManaged public private(set) var bookmarked: Set<Status>?
@NSManaged public private(set) var votePollOptions: Set<PollOption>?
@NSManaged public private(set) var votePolls: Set<Poll>?
// relationships
@NSManaged public private(set) var following: Set<MastodonUser>?
@NSManaged public private(set) var followingBy: Set<MastodonUser>?
@NSManaged public private(set) var followRequested: Set<MastodonUser>?
@NSManaged public private(set) var followRequestedBy: Set<MastodonUser>?
@NSManaged public private(set) var muting: Set<MastodonUser>?
@NSManaged public private(set) var mutingBy: Set<MastodonUser>?
@NSManaged public private(set) var blocking: Set<MastodonUser>?
@NSManaged public private(set) var blockingBy: Set<MastodonUser>?
@NSManaged public private(set) var endorsed: Set<MastodonUser>?
@NSManaged public private(set) var endorsedBy: Set<MastodonUser>?
@NSManaged public private(set) var domainBlocking: Set<MastodonUser>?
@NSManaged public private(set) var domainBlockingBy: Set<MastodonUser>?
}
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.header = property.header
user.headerStatic = property.headerStatic
user.note = property.note
user.url = property.url
user.emojisData = property.emojisData
user.fieldsData = property.fieldsData
user.statusesCount = NSNumber(value: property.statusesCount)
user.followingCount = NSNumber(value: property.followingCount)
user.followersCount = NSNumber(value: property.followersCount)
user.locked = property.locked
user.bot = property.bot ?? false
user.suspended = property.suspended ?? false
// Mastodon do not provide relationship on the `Account`
// Update relationship via attribute updating interface
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 update(header: String) {
if self.header != header {
self.header = header
}
}
public func update(headerStatic: String?) {
if self.headerStatic != headerStatic {
self.headerStatic = headerStatic
}
}
public func update(note: String?) {
if self.note != note {
self.note = note
}
}
public func update(url: String?) {
if self.url != url {
self.url = url
}
}
public func update(emojisData: Data?) {
if self.emojisData != emojisData {
self.emojisData = emojisData
}
}
public func update(fieldsData: Data?) {
if self.fieldsData != fieldsData {
self.fieldsData = fieldsData
}
}
public func update(statusesCount: Int) {
if self.statusesCount.intValue != statusesCount {
self.statusesCount = NSNumber(value: statusesCount)
}
}
public func update(followingCount: Int) {
if self.followingCount.intValue != followingCount {
self.followingCount = NSNumber(value: followingCount)
}
}
public func update(followersCount: Int) {
if self.followersCount.intValue != followersCount {
self.followersCount = NSNumber(value: followersCount)
}
}
public func update(locked: Bool) {
if self.locked != locked {
self.locked = locked
}
}
public func update(bot: Bool) {
if self.bot != bot {
self.bot = bot
}
}
public func update(suspended: Bool) {
if self.suspended != suspended {
self.suspended = suspended
}
}
public func update(isFollowing: Bool, by mastodonUser: MastodonUser) {
if isFollowing {
if !(self.followingBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(MastodonUser.followingBy)).add(mastodonUser)
}
} else {
if (self.followingBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(MastodonUser.followingBy)).remove(mastodonUser)
}
}
}
public func update(isFollowRequested: Bool, by mastodonUser: MastodonUser) {
if isFollowRequested {
if !(self.followRequestedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(MastodonUser.followRequestedBy)).add(mastodonUser)
}
} else {
if (self.followRequestedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(MastodonUser.followRequestedBy)).remove(mastodonUser)
}
}
}
public func update(isMuting: Bool, by mastodonUser: MastodonUser) {
if isMuting {
if !(self.mutingBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(MastodonUser.mutingBy)).add(mastodonUser)
}
} else {
if (self.mutingBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(MastodonUser.mutingBy)).remove(mastodonUser)
}
}
}
public func update(isBlocking: Bool, by mastodonUser: MastodonUser) {
if isBlocking {
if !(self.blockingBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(MastodonUser.blockingBy)).add(mastodonUser)
}
} else {
if (self.blockingBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(MastodonUser.blockingBy)).remove(mastodonUser)
}
}
}
public func update(isEndorsed: Bool, by mastodonUser: MastodonUser) {
if isEndorsed {
if !(self.endorsedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(MastodonUser.endorsedBy)).add(mastodonUser)
}
} else {
if (self.endorsedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(MastodonUser.endorsedBy)).remove(mastodonUser)
}
}
}
public func update(isDomainBlocking: Bool, by mastodonUser: MastodonUser) {
if isDomainBlocking {
if !(self.domainBlockingBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(MastodonUser.domainBlockingBy)).add(mastodonUser)
}
} else {
if (self.domainBlockingBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(MastodonUser.domainBlockingBy)).remove(mastodonUser)
}
}
}
public func didUpdate(at networkDate: Date) {
self.updatedAt = networkDate
}
}
extension MastodonUser {
public func findSearchHistory(domain: String, userID: MastodonUser.ID) -> SearchHistory? {
return searchHistories.first { searchHistory in
return searchHistory.domain == domain
&& searchHistory.userID == userID
}
}
}
extension MastodonUser {
public 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 header: String
public let headerStatic: String?
public let note: String?
public let url: String?
public let emojisData: Data?
public let fieldsData: Data?
public let statusesCount: Int
public let followingCount: Int
public let followersCount: Int
public let locked: Bool
public let bot: Bool?
public let suspended: Bool?
public let createdAt: Date
public let networkDate: Date
public init(
id: String,
domain: String,
acct: String,
username: String,
displayName: String,
avatar: String,
avatarStatic: String?,
header: String,
headerStatic: String?,
note: String?,
url: String?,
emojisData: Data?,
fieldsData: Data?,
statusesCount: Int,
followingCount: Int,
followersCount: Int,
locked: Bool,
bot: Bool?,
suspended: Bool?,
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.header = header
self.headerStatic = headerStatic
self.note = note
self.url = url
self.emojisData = emojisData
self.fieldsData = fieldsData
self.statusesCount = statusesCount
self.followingCount = followingCount
self.followersCount = followersCount
self.locked = locked
self.bot = bot
self.suspended = suspended
self.createdAt = createdAt
self.networkDate = networkDate
}
}
}
extension MastodonUser: Managed {
public static var defaultSortDescriptors: [NSSortDescriptor] {
return [NSSortDescriptor(keyPath: \MastodonUser.createdAt, ascending: false)]
}
}
extension MastodonUser {
static func predicate(domain: String) -> NSPredicate {
return NSPredicate(format: "%K == %@", #keyPath(MastodonUser.domain), domain)
}
static func predicate(id: String) -> NSPredicate {
return NSPredicate(format: "%K == %@", #keyPath(MastodonUser.id), id)
}
public static func predicate(domain: String, id: String) -> NSPredicate {
return NSCompoundPredicate(andPredicateWithSubpredicates: [
MastodonUser.predicate(domain: domain),
MastodonUser.predicate(id: id)
])
}
static func predicate(ids: [String]) -> NSPredicate {
return NSPredicate(format: "%K IN %@", #keyPath(MastodonUser.id), ids)
}
public static func predicate(domain: String, ids: [String]) -> NSPredicate {
return NSCompoundPredicate(andPredicateWithSubpredicates: [
MastodonUser.predicate(domain: domain),
MastodonUser.predicate(ids: ids)
])
}
static func predicate(username: String) -> NSPredicate {
return NSPredicate(format: "%K == %@", #keyPath(MastodonUser.username), username)
}
public static func predicate(domain: String, username: String) -> NSPredicate {
return NSCompoundPredicate(andPredicateWithSubpredicates: [
MastodonUser.predicate(domain: domain),
MastodonUser.predicate(username: username)
])
}
}

View File

@ -1,71 +0,0 @@
//
// 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 index: NSNumber
@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 status: Status
}
public extension Mention {
override func awakeFromInsert() {
super.awakeFromInsert()
setPrimitiveValue(UUID(), forKey: #keyPath(Mention.identifier))
}
@discardableResult
static func insert(
into context: NSManagedObjectContext,
property: Property,
index: Int
) -> Mention {
let mention: Mention = context.insertObject()
mention.index = NSNumber(value: index)
mention.id = property.id
mention.username = property.username
mention.acct = property.acct
mention.url = property.url
return mention
}
}
public extension Mention {
struct Property {
public let id: String
public let username: String
public let acct: String
public let url: String
public init(id: String, username: String, acct: String, url: String) {
self.id = id
self.username = username
self.acct = acct
self.url = url
}
}
}
extension Mention: Managed {
public static var defaultSortDescriptors: [NSSortDescriptor] {
return [NSSortDescriptor(keyPath: \Mention.createAt, ascending: false)]
}
}

View File

@ -1,115 +0,0 @@
//
// MastodonNotification.swift
// CoreDataStack
//
// Created by sxiaojian on 2021/4/13.
//
import Foundation
import CoreData
public final class MastodonNotification: 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 updatedAt: Date
@NSManaged public private(set) var typeRaw: String
@NSManaged public private(set) var account: MastodonUser
@NSManaged public private(set) var status: Status?
@NSManaged public private(set) var domain: String
@NSManaged public private(set) var userID: String
}
extension MastodonNotification {
public override func awakeFromInsert() {
super.awakeFromInsert()
setPrimitiveValue(UUID(), forKey: #keyPath(MastodonNotification.identifier))
}
}
public extension MastodonNotification {
@discardableResult
static func insert(
into context: NSManagedObjectContext,
domain: String,
userID: String,
networkDate: Date,
property: Property
) -> MastodonNotification {
let notification: MastodonNotification = context.insertObject()
notification.id = property.id
notification.createAt = property.createdAt
notification.updatedAt = networkDate
notification.typeRaw = property.typeRaw
notification.account = property.account
notification.status = property.status
notification.domain = domain
notification.userID = userID
return notification
}
}
public extension MastodonNotification {
struct Property {
public init(id: String,
typeRaw: String,
account: MastodonUser,
status: Status?,
createdAt: Date
) {
self.id = id
self.typeRaw = typeRaw
self.account = account
self.status = status
self.createdAt = createdAt
}
public let id: String
public let typeRaw: String
public let account: MastodonUser
public let status: Status?
public let createdAt: Date
}
}
extension MastodonNotification {
static func predicate(domain: String) -> NSPredicate {
return NSPredicate(format: "%K == %@", #keyPath(MastodonNotification.domain), domain)
}
static func predicate(userID: String) -> NSPredicate {
return NSPredicate(format: "%K == %@", #keyPath(MastodonNotification.userID), userID)
}
static func predicate(typeRaw: String) -> NSPredicate {
return NSPredicate(format: "%K == %@", #keyPath(MastodonNotification.typeRaw), typeRaw)
}
public static func predicate(domain: String, userID: String, typeRaw: String? = nil) -> NSPredicate {
if let typeRaw = typeRaw {
return NSCompoundPredicate(andPredicateWithSubpredicates: [
MastodonNotification.predicate(domain: domain),
MastodonNotification.predicate(typeRaw: typeRaw),
MastodonNotification.predicate(userID: userID),
])
} else {
return NSCompoundPredicate(andPredicateWithSubpredicates: [
MastodonNotification.predicate(domain: domain),
MastodonNotification.predicate(userID: userID)
])
}
}
public static func predicate(validTypesRaws types: [String]) -> NSPredicate {
return NSPredicate(format: "%K IN %@", #keyPath(MastodonNotification.typeRaw), types)
}
}
extension MastodonNotification: Managed {
public static var defaultSortDescriptors: [NSSortDescriptor] {
return [NSSortDescriptor(keyPath: \MastodonNotification.createAt, ascending: false)]
}
}

View File

@ -1,145 +0,0 @@
//
// Poll.swift
// CoreDataStack
//
// Created by MainasuK Cirno on 2021-3-2.
//
import Foundation
import CoreData
public final class Poll: NSManagedObject {
public typealias ID = String
@NSManaged public private(set) var id: ID
@NSManaged public private(set) var expiresAt: Date?
@NSManaged public private(set) var expired: Bool
@NSManaged public private(set) var multiple: Bool
@NSManaged public private(set) var votesCount: NSNumber
@NSManaged public private(set) var votersCount: NSNumber?
@NSManaged public private(set) var createdAt: Date
@NSManaged public private(set) var updatedAt: Date
// one-to-one relationship
@NSManaged public private(set) var status: Status
// one-to-many relationship
@NSManaged public private(set) var options: Set<PollOption>
// many-to-many relationship
@NSManaged public private(set) var votedBy: Set<MastodonUser>?
}
extension Poll {
public override func awakeFromInsert() {
super.awakeFromInsert()
setPrimitiveValue(Date(), forKey: #keyPath(Poll.createdAt))
}
@discardableResult
public static func insert(
into context: NSManagedObjectContext,
property: Property,
votedBy: MastodonUser?,
options: [PollOption]
) -> Poll {
let poll: Poll = context.insertObject()
poll.id = property.id
poll.expiresAt = property.expiresAt
poll.expired = property.expired
poll.multiple = property.multiple
poll.votesCount = property.votesCount
poll.votersCount = property.votersCount
poll.updatedAt = property.networkDate
if let votedBy = votedBy {
poll.mutableSetValue(forKey: #keyPath(Poll.votedBy)).add(votedBy)
}
poll.mutableSetValue(forKey: #keyPath(Poll.options)).addObjects(from: options)
return poll
}
public func update(expiresAt: Date?) {
if self.expiresAt != expiresAt {
self.expiresAt = expiresAt
}
}
public func update(expired: Bool) {
if self.expired != expired {
self.expired = expired
}
}
public func update(votesCount: Int) {
if self.votesCount.intValue != votesCount {
self.votesCount = NSNumber(value: votesCount)
}
}
public func update(votersCount: Int?) {
if self.votersCount?.intValue != votersCount {
self.votersCount = votersCount.flatMap { NSNumber(value: $0) }
}
}
public func update(voted: Bool, by: MastodonUser) {
if voted {
if !(votedBy ?? Set()).contains(by) {
mutableSetValue(forKey: #keyPath(Poll.votedBy)).add(by)
}
} else {
if (votedBy ?? Set()).contains(by) {
mutableSetValue(forKey: #keyPath(Poll.votedBy)).remove(by)
}
}
}
public func didUpdate(at networkDate: Date) {
self.updatedAt = networkDate
}
}
extension Poll {
public struct Property {
public let id: ID
public let expiresAt: Date?
public let expired: Bool
public let multiple: Bool
public let votesCount: NSNumber
public let votersCount: NSNumber?
public let networkDate: Date
public init(
id: Poll.ID,
expiresAt: Date?,
expired: Bool,
multiple: Bool,
votesCount: Int,
votersCount: Int?,
networkDate: Date
) {
self.id = id
self.expiresAt = expiresAt
self.expired = expired
self.multiple = multiple
self.votesCount = NSNumber(value: votesCount)
self.votersCount = votersCount.flatMap { NSNumber(value: $0) }
self.networkDate = networkDate
}
}
}
extension Poll: Managed {
public static var defaultSortDescriptors: [NSSortDescriptor] {
return [NSSortDescriptor(keyPath: \Poll.createdAt, ascending: false)]
}
}

View File

@ -1,98 +0,0 @@
//
// PollOption.swift
// CoreDataStack
//
// Created by MainasuK Cirno on 2021-3-2.
//
import Foundation
import CoreData
public final class PollOption: NSManagedObject {
@NSManaged public private(set) var index: NSNumber
@NSManaged public private(set) var title: String
@NSManaged public private(set) var votesCount: NSNumber?
@NSManaged public private(set) var createdAt: Date
@NSManaged public private(set) var updatedAt: Date
// many-to-one relationship
@NSManaged public private(set) var poll: Poll
// many-to-many relationship
@NSManaged public private(set) var votedBy: Set<MastodonUser>?
}
extension PollOption {
public override func awakeFromInsert() {
super.awakeFromInsert()
setPrimitiveValue(Date(), forKey: #keyPath(PollOption.createdAt))
}
@discardableResult
public static func insert(
into context: NSManagedObjectContext,
property: Property,
votedBy: MastodonUser?
) -> PollOption {
let option: PollOption = context.insertObject()
option.index = property.index
option.title = property.title
option.votesCount = property.votesCount
option.updatedAt = property.networkDate
if let votedBy = votedBy {
option.mutableSetValue(forKey: #keyPath(PollOption.votedBy)).add(votedBy)
}
return option
}
public func update(votesCount: Int?) {
if self.votesCount?.intValue != votesCount {
self.votesCount = votesCount.flatMap { NSNumber(value: $0) }
}
}
public func update(voted: Bool, by: MastodonUser) {
if voted {
if !(self.votedBy ?? Set()).contains(by) {
self.mutableSetValue(forKey: #keyPath(PollOption.votedBy)).add(by)
}
} else {
if (self.votedBy ?? Set()).contains(by) {
self.mutableSetValue(forKey: #keyPath(PollOption.votedBy)).remove(by)
}
}
}
public func didUpdate(at networkDate: Date) {
self.updatedAt = networkDate
}
}
extension PollOption {
public struct Property {
public let index: NSNumber
public let title: String
public let votesCount: NSNumber?
public let networkDate: Date
public init(index: Int, title: String, votesCount: Int?, networkDate: Date) {
self.index = NSNumber(value: index)
self.title = title
self.votesCount = votesCount.flatMap { NSNumber(value: $0) }
self.networkDate = networkDate
}
}
}
extension PollOption: Managed {
public static var defaultSortDescriptors: [NSSortDescriptor] {
return [NSSortDescriptor(keyPath: \PollOption.createdAt, ascending: false)]
}
}

View File

@ -1,118 +0,0 @@
//
// SearchHistory.swift
// CoreDataStack
//
// Created by sxiaojian on 2021/4/7.
//
import Foundation
import CoreData
public final class SearchHistory: 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: MastodonUser.ID
@NSManaged public private(set) var createAt: Date
@NSManaged public private(set) var updatedAt: Date
// many-to-one relationship
@NSManaged public private(set) var account: MastodonUser?
@NSManaged public private(set) var hashtag: Tag?
@NSManaged public private(set) var status: Status?
}
extension SearchHistory {
public override func awakeFromInsert() {
super.awakeFromInsert()
setPrimitiveValue(UUID(), forKey: #keyPath(SearchHistory.identifier))
setPrimitiveValue(Date(), forKey: #keyPath(SearchHistory.createAt))
setPrimitiveValue(Date(), forKey: #keyPath(SearchHistory.updatedAt))
}
// public override func willSave() {
// super.willSave()
// setPrimitiveValue(Date(), forKey: #keyPath(SearchHistory.updatedAt))
// }
@discardableResult
public static func insert(
into context: NSManagedObjectContext,
property: Property,
account: MastodonUser
) -> SearchHistory {
let searchHistory: SearchHistory = context.insertObject()
searchHistory.domain = property.domain
searchHistory.userID = property.userID
searchHistory.account = account
return searchHistory
}
@discardableResult
public static func insert(
into context: NSManagedObjectContext,
property: Property,
hashtag: Tag
) -> SearchHistory {
let searchHistory: SearchHistory = context.insertObject()
searchHistory.domain = property.domain
searchHistory.userID = property.userID
searchHistory.hashtag = hashtag
return searchHistory
}
@discardableResult
public static func insert(
into context: NSManagedObjectContext,
property: Property,
status: Status
) -> SearchHistory {
let searchHistory: SearchHistory = context.insertObject()
searchHistory.domain = property.domain
searchHistory.userID = property.userID
searchHistory.status = status
return searchHistory
}
}
extension SearchHistory {
public func update(updatedAt: Date) {
setValue(updatedAt, forKey: #keyPath(SearchHistory.updatedAt))
}
}
extension SearchHistory {
public struct Property {
public let domain: String
public let userID: MastodonUser.ID
public init(domain: String, userID: MastodonUser.ID) {
self.domain = domain
self.userID = userID
}
}
}
extension SearchHistory: Managed {
public static var defaultSortDescriptors: [NSSortDescriptor] {
return [NSSortDescriptor(keyPath: \SearchHistory.updatedAt, ascending: false)]
}
}
extension SearchHistory {
static func predicate(domain: String) -> NSPredicate {
return NSPredicate(format: "%K == %@", #keyPath(SearchHistory.domain), domain)
}
static func predicate(userID: String) -> NSPredicate {
return NSPredicate(format: "%K == %@", #keyPath(SearchHistory.userID), userID)
}
public static func predicate(domain: String, userID: String) -> NSPredicate {
return NSCompoundPredicate(andPredicateWithSubpredicates: [
predicate(domain: domain),
predicate(userID: userID)
])
}
}

View File

@ -1,355 +0,0 @@
//
// Status.swift
// CoreDataStack
//
// Created by MainasuK Cirno on 2021/1/27.
//
import CoreData
import Foundation
public final class Status: 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?
@NSManaged public private(set) var emojisData: Data?
// 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: Status.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 relationship
@NSManaged public private(set) var author: MastodonUser
@NSManaged public private(set) var reblog: Status?
@NSManaged public private(set) var replyTo: Status?
// many-to-many relationship
@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 relationship
@NSManaged public private(set) var pinnedBy: MastodonUser?
@NSManaged public private(set) var poll: Poll?
// one-to-many relationship
@NSManaged public private(set) var reblogFrom: Set<Status>?
@NSManaged public private(set) var mentions: Set<Mention>?
@NSManaged public private(set) var homeTimelineIndexes: Set<HomeTimelineIndex>?
@NSManaged public private(set) var mediaAttachments: Set<Attachment>?
@NSManaged public private(set) var replyFrom: Set<Status>?
@NSManaged public private(set) var inNotifications: Set<MastodonNotification>?
@NSManaged public private(set) var searchHistories: Set<SearchHistory>
@NSManaged public private(set) var updatedAt: Date
@NSManaged public private(set) var deletedAt: Date?
@NSManaged public private(set) var revealedAt: Date?
}
extension Status {
@discardableResult
public static func insert(
into context: NSManagedObjectContext,
property: Property,
author: MastodonUser,
reblog: Status?,
application: Application?,
replyTo: Status?,
poll: Poll?,
mentions: [Mention]?,
mediaAttachments: [Attachment]?,
favouritedBy: MastodonUser?,
rebloggedBy: MastodonUser?,
mutedBy: MastodonUser?,
bookmarkedBy: MastodonUser?,
pinnedBy: MastodonUser?
) -> Status {
let status: Status = context.insertObject()
status.identifier = property.identifier
status.domain = property.domain
status.id = property.id
status.uri = property.uri
status.createdAt = property.createdAt
status.content = property.content
status.visibility = property.visibility
status.sensitive = property.sensitive
status.spoilerText = property.spoilerText
status.application = application
status.emojisData = property.emojisData
status.reblogsCount = property.reblogsCount
status.favouritesCount = property.favouritesCount
status.repliesCount = property.repliesCount
status.url = property.url
status.inReplyToID = property.inReplyToID
status.inReplyToAccountID = property.inReplyToAccountID
status.language = property.language
status.text = property.text
status.author = author
status.reblog = reblog
status.pinnedBy = pinnedBy
status.poll = poll
if let mentions = mentions {
status.mutableSetValue(forKey: #keyPath(Status.mentions)).addObjects(from: mentions)
}
if let mediaAttachments = mediaAttachments {
status.mutableSetValue(forKey: #keyPath(Status.mediaAttachments)).addObjects(from: mediaAttachments)
}
if let favouritedBy = favouritedBy {
status.mutableSetValue(forKey: #keyPath(Status.favouritedBy)).add(favouritedBy)
}
if let rebloggedBy = rebloggedBy {
status.mutableSetValue(forKey: #keyPath(Status.rebloggedBy)).add(rebloggedBy)
}
if let mutedBy = mutedBy {
status.mutableSetValue(forKey: #keyPath(Status.mutedBy)).add(mutedBy)
}
if let bookmarkedBy = bookmarkedBy {
status.mutableSetValue(forKey: #keyPath(Status.bookmarkedBy)).add(bookmarkedBy)
}
status.updatedAt = property.networkDate
return status
}
public func update(emojisData: Data?) {
if self.emojisData != emojisData {
self.emojisData = emojisData
}
}
public func update(reblogsCount: NSNumber) {
if self.reblogsCount.intValue != reblogsCount.intValue {
self.reblogsCount = reblogsCount
}
}
public func update(favouritesCount: NSNumber) {
if self.favouritesCount.intValue != favouritesCount.intValue {
self.favouritesCount = favouritesCount
}
}
public func update(repliesCount: NSNumber?) {
guard let count = repliesCount else {
return
}
if self.repliesCount?.intValue != count.intValue {
self.repliesCount = repliesCount
}
}
public func update(replyTo: Status?) {
if self.replyTo != replyTo {
self.replyTo = replyTo
}
}
public func update(liked: Bool, by mastodonUser: MastodonUser) {
if liked {
if !(self.favouritedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(Status.favouritedBy)).add(mastodonUser)
}
} else {
if (self.favouritedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(Status.favouritedBy)).remove(mastodonUser)
}
}
}
public func update(reblogged: Bool, by mastodonUser: MastodonUser) {
if reblogged {
if !(self.rebloggedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(Status.rebloggedBy)).add(mastodonUser)
}
} else {
if (self.rebloggedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(Status.rebloggedBy)).remove(mastodonUser)
}
}
}
public func update(muted: Bool, by mastodonUser: MastodonUser) {
if muted {
if !(self.mutedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(Status.mutedBy)).add(mastodonUser)
}
} else {
if (self.mutedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(Status.mutedBy)).remove(mastodonUser)
}
}
}
public func update(bookmarked: Bool, by mastodonUser: MastodonUser) {
if bookmarked {
if !(self.bookmarkedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(Status.bookmarkedBy)).add(mastodonUser)
}
} else {
if (self.bookmarkedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(Status.bookmarkedBy)).remove(mastodonUser)
}
}
}
public func update(isReveal: Bool) {
revealedAt = isReveal ? Date() : nil
}
public func didUpdate(at networkDate: Date) {
self.updatedAt = networkDate
}
}
extension Status {
public 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 emojisData: Data?
public let reblogsCount: NSNumber
public let favouritesCount: NSNumber
public let repliesCount: NSNumber?
public let url: String?
public let inReplyToID: Status.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?,
emojisData: Data?,
reblogsCount: NSNumber,
favouritesCount: NSNumber,
repliesCount: NSNumber?,
url: String?,
inReplyToID: Status.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.emojisData = emojisData
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 Status: Managed {
public static var defaultSortDescriptors: [NSSortDescriptor] {
return [NSSortDescriptor(keyPath: \Status.createdAt, ascending: false)]
}
}
extension Status {
static func predicate(domain: String) -> NSPredicate {
return NSPredicate(format: "%K == %@", #keyPath(Status.domain), domain)
}
static func predicate(id: String) -> NSPredicate {
return NSPredicate(format: "%K == %@", #keyPath(Status.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(Status.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(Status.deletedAt))
}
public static func deleted() -> NSPredicate {
return NSPredicate(format: "%K != nil", #keyPath(Status.deletedAt))
}
}

View File

@ -1,112 +0,0 @@
//
// 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 updatedAt: Date
@NSManaged public private(set) var name: String
@NSManaged public private(set) var url: String
// one-to-one relationship
// many-to-many relationship
// one-to-many relationship
@NSManaged public private(set) var histories: Set<History>?
@NSManaged public private(set) var searchHistories: Set<SearchHistory>
}
public extension Tag {
override func awakeFromInsert() {
super.awakeFromInsert()
setPrimitiveValue(UUID(), forKey: #keyPath(Tag.identifier))
setPrimitiveValue(Date(), forKey: #keyPath(Tag.createAt))
setPrimitiveValue(Date(), forKey: #keyPath(Tag.updatedAt))
}
override func willSave() {
super.willSave()
setPrimitiveValue(Date(), forKey: #keyPath(Tag.updatedAt))
}
@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
}
}
extension Tag {
public func findSearchHistory(domain: String, userID: MastodonUser.ID) -> SearchHistory? {
return searchHistories.first { searchHistory in
return searchHistory.domain == domain
&& searchHistory.userID == userID
}
}
}
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
}
}
}
public extension Tag {
func updateHistory(index: Int, day: Date, uses: String, account: String) {
guard let histories = self.histories?.sorted(by: {
$0.createAt.compare($1.createAt) == .orderedAscending
}) else { return }
let history = histories[index]
history.update(day: day)
history.update(uses: uses)
history.update(accounts: account)
}
func appendHistory(history: History) {
self.mutableSetValue(forKeyPath: #keyPath(Tag.histories)).add(history)
}
func update(url: String) {
if self.url != url {
self.url = url
}
}
}
extension Tag: Managed {
public static var defaultSortDescriptors: [NSSortDescriptor] {
[NSSortDescriptor(keyPath: \Tag.createAt, ascending: false)]
}
}
public extension Tag {
static func predicate(name: String) -> NSPredicate {
NSPredicate(format: "%K == %@", #keyPath(Tag.name), name)
}
}

View File

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

View File

@ -1,22 +0,0 @@
<?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.3.0</string>
<key>CFBundleVersion</key>
<string>90</string>
</dict>
</plist>

View File

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

View File

@ -1,22 +0,0 @@
<?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.3.0</string>
<key>CFBundleVersion</key>
<string>90</string>
</dict>
</plist>

View File

@ -1,11 +1,6 @@
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)
// conver i18n JSON templates to strings files
private func convert(from inputDirectoryURL: URL, to outputDirectory: URL) {
do {
@ -17,7 +12,6 @@ private func convert(from inputDirectoryURL: URL, to outputDirectory: URL) {
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(
@ -29,9 +23,19 @@ private func convert(from inputDirectoryURL: URL, to outputDirectory: URL) {
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")
guard let bundle = bundle(filename: filename) else { continue }
let outputDirectoryURL = outputDirectory
.appendingPathComponent(bundle, isDirectory: true)
.appendingPathComponent(mappedLanguage + ".lproj", isDirectory: true)
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)
}
}
@ -44,6 +48,7 @@ private func convert(from inputDirectoryURL: URL, to outputDirectory: URL) {
private func map(language: String) -> String? {
switch language {
case "ar_SA": return "ar" // Arabic (Saudi Arabia)
case "eu_ES": return "eu-ES" // Basque
case "ca_ES": return "ca" // Catalan
case "zh_CN": return "zh-Hans" // Chinese Simplified
case "nl_NL": return "nl" // Dutch
@ -56,6 +61,7 @@ private func map(language: String) -> String? {
case "gd_GB": return "gd-GB" // Scottish Gaelic
case "es_ES": return "es" // Spanish
case "es_AR": return "es-419" // Spanish, Argentina
case "sv_FI": return "sv_FI" // Swedish, Finland
case "th_TH": return "th" // Thai
default: return nil
}
@ -69,6 +75,14 @@ private func map(filename: String) -> (filename: String, keyStyle: Parser.KeySty
}
}
private func bundle(filename: String) -> String? {
switch filename {
case "app": return "module"
case "ios-infoPlist": return "main"
default: return nil
}
}
private func process(url: URL, keyStyle: Parser.KeyStyle) throws -> String {
do {
let data = try Data(contentsOf: url)
@ -115,9 +129,16 @@ private func move(from inputDirectoryURL: URL, to outputDirectoryURL: URL, pathE
}
}
// i18n from "input" to "output"
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)
convert(from: inputDirectoryURL, to: outputDirectoryURL)
move(from: inputDirectoryURL, to: outputDirectoryURL, pathExtension: "stringsdict")
let moduleDirectoryURL = outputDirectoryURL.appendingPathComponent("module", isDirectory: true)
move(from: inputDirectoryURL, to: moduleDirectoryURL, pathExtension: "stringsdict")
// i18n from "Intents/input" to "Intents/output"
let intentsDirectoryURL = packageRootURL.appendingPathComponent("Intents", isDirectory: true)

View File

@ -45,8 +45,8 @@
"message": "Please enable the photo library access permission to save the photo."
},
"delete_post": {
"title": "Are you sure you want to delete this post?",
"delete": "Delete"
"title": "Delete Post",
"message": "Are you sure you want to delete this post?"
},
"clean_cache": {
"title": "Clean Cache",
@ -140,7 +140,8 @@
"unreblog": "Undo reblog",
"favorite": "Favorite",
"unfavorite": "Unfavorite",
"menu": "Menu"
"menu": "Menu",
"hide": "Hide"
},
"tag": {
"url": "URL",
@ -149,6 +150,12 @@
"hashtag": "Hashtag",
"email": "Email",
"emoji": "Emoji"
},
"visibility": {
"unlisted": "Everyone can see this post but not display in the public timeline.",
"private": "Only their followers can see this post.",
"private_from_me": "Only my followers can see this post.",
"direct": "Only mentioned user can see this post."
}
},
"friendship": {
@ -412,14 +419,24 @@
"segmented_control": {
"posts": "Posts",
"replies": "Replies",
"media": "Media"
"posts_and_replies": "Posts and Replies",
"media": "Media",
"about": "About"
},
"relationship_action_alert": {
"confirm_mute_user": {
"title": "Mute Account",
"message": "Confirm to mute %s"
},
"confirm_unmute_user": {
"title": "Unmute Account",
"message": "Confirm to unmute %s"
},
"confirm_unblock_usre": {
"confirm_block_user": {
"title": "Block Account",
"message": "Confirm to block %s"
},
"confirm_unblock_user": {
"title": "Unblock Account",
"message": "Confirm to unblock %s"
}
@ -472,12 +489,14 @@
"Everything": "Everything",
"Mentions": "Mentions"
},
"user_followed_you": "%s followed you",
"user_favorited your post": "%s favorited your post",
"user_reblogged_your_post": "%s reblogged your post",
"user_mentioned_you": "%s mentioned you",
"user_requested_to_follow_you": "%s requested to follow you",
"user_your_poll_has_ended": "%s Your poll has ended",
"notification_description": {
"followed_you": "followd you",
"favorited_your_post": "favorited your post",
"reblogged_your_post": "reblogged your post",
"mentioned_you": "mentioned you",
"request_to_follow_you": "request to follow you",
"poll_has_ended": "poll has ended"
},
"keyobard": {
"show_everything": "Show Everything",
"show_mentions": "Show Mentions"
@ -496,6 +515,13 @@
"light": "Always Light",
"dark": "Always Dark"
},
"look_and_feel": {
"title": "Look and Feel",
"use_system": "Use System",
"really_dark": "Really Dark",
"sorta_dark": "Sorta Dark",
"light": "Light"
},
"notifications": {
"title": "Notifications",
"favorites": "Favorites my post",
@ -537,14 +563,17 @@
}
},
"report": {
"title_report": "Report",
"title": "Report %s",
"step1": "Step 1 of 2",
"step2": "Step 2 of 2",
"content1": "Are there any other posts youd like to add to the report?",
"content2": "Is there anything the moderators should know about this report?",
"report_sent_title": "Thanks for reporting, well look into this.",
"send": "Send Report",
"skip_to_send": "Send without comment",
"text_placeholder": "Type or paste additional comments"
"text_placeholder": "Type or paste additional comments",
"reported": "REPORTED"
},
"preview": {
"keyboard": {
@ -564,4 +593,4 @@
"accessibility_hint": "Double tap to dismiss this wizard"
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,108 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1250"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "DB427DD125BAA00100D1B89D"
BuildableName = "Mastodon.app"
BlueprintName = "Mastodon"
ReferencedContainer = "container:Mastodon.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "ASDK - Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "DB427DE725BAA00100D1B89D"
BuildableName = "MastodonTests.xctest"
BlueprintName = "MastodonTests"
ReferencedContainer = "container:Mastodon.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "DB427DF225BAA00100D1B89D"
BuildableName = "MastodonUITests.xctest"
BlueprintName = "MastodonUITests"
ReferencedContainer = "container:Mastodon.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "DB89B9F525C10FD0008580ED"
BuildableName = "CoreDataStackTests.xctest"
BlueprintName = "CoreDataStackTests"
ReferencedContainer = "container:Mastodon.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "ASDK - Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "DB427DD125BAA00100D1B89D"
BuildableName = "Mastodon.app"
BlueprintName = "Mastodon"
ReferencedContainer = "container:Mastodon.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "ASDK - Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "DB427DD125BAA00100D1B89D"
BuildableName = "Mastodon.app"
BlueprintName = "Mastodon"
ReferencedContainer = "container:Mastodon.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "ASDK - Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "ASDK - Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -7,18 +7,13 @@
<key>AppShared.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>26</integer>
<integer>33</integer>
</dict>
<key>CoreDataStack.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>27</integer>
</dict>
<key>Mastodon - ASDK.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>2</integer>
</dict>
<key>Mastodon - RTL.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
@ -102,7 +97,7 @@
<key>MastodonIntent.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>25</integer>
<integer>32</integer>
</dict>
<key>MastodonIntents.xcscheme_^#shared#^_</key>
<dict>
@ -117,15 +112,36 @@
<key>NotificationService.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>3</integer>
<integer>2</integer>
</dict>
<key>ShareActionExtension.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>24</integer>
<integer>31</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict/>
<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>
</plist>

View File

@ -55,15 +55,6 @@
"version": "1.2.0"
}
},
{
"package": "FLAnimatedImage",
"repositoryURL": "https://github.com/Flipboard/FLAnimatedImage",
"state": {
"branch": null,
"revision": "e7f9fd4681ae41bf6f3056db08af4f401d61da52",
"version": "1.0.16"
}
},
{
"package": "FPSIndicator",
"repositoryURL": "https://github.com/MainasuK/FPSIndicator.git",
@ -96,8 +87,8 @@
"repositoryURL": "https://github.com/TwidereProject/MetaTextKit.git",
"state": {
"branch": null,
"revision": "7af4182f64329440a4656f2cba307cb5848e496a",
"version": "2.1.2"
"revision": "3ea336d3de7938dc112084c596a646e697b0feee",
"version": "2.2.1"
}
},
{
@ -141,8 +132,8 @@
"repositoryURL": "https://github.com/SDWebImage/SDWebImage.git",
"state": {
"branch": null,
"revision": "0fff0d7505b5306348263ea64fcc561253bbeb21",
"version": "5.12.2"
"revision": "2c53f531f1bedd253f55d85105409c28ed4a922c",
"version": "5.12.3"
}
},
{
@ -195,8 +186,8 @@
"repositoryURL": "https://github.com/uias/Tabman",
"state": {
"branch": null,
"revision": "f43489cdd743ba7ad86a422ebb5fcbf34e333df4",
"version": "2.11.1"
"revision": "a9f10cb862a32e6a22549836af013abd6b0692d3",
"version": "2.12.0"
}
},
{
@ -213,8 +204,8 @@
"repositoryURL": "https://github.com/TimOliver/TOCropViewController.git",
"state": {
"branch": null,
"revision": "dad97167bf1be16aeecd109130900995dd01c515",
"version": "2.6.0"
"revision": "d0470491f56e734731bbf77991944c0dfdee3e0e",
"version": "2.6.1"
}
}
]

7
Mastodon/.sourcery.yml Normal file
View File

@ -0,0 +1,7 @@
sources:
- .
- ../MastodonSDK/Sources
templates:
- ./Template
output:
Generated

View File

@ -7,6 +7,8 @@
import UIKit
import SafariServices
import MastodonAsset
import MastodonLocalization
final class SafariActivity: UIActivity {
@ -55,8 +57,10 @@ final class SafariActivity: UIActivity {
return
}
sceneCoordinator?.present(scene: .safari(url: url as URL), from: nil, transition: .safariPresent(animated: true, completion: nil))
activityDidFinish(true)
Task {
await sceneCoordinator?.present(scene: .safari(url: url as URL), from: nil, transition: .safariPresent(animated: true, completion: nil))
activityDidFinish(true)
}
}
}

View File

@ -0,0 +1,13 @@
//
// ShareActivityProvider.swift
// Mastodon
//
// Created by MainasuK on 2022-1-25.
//
import UIKit
protocol ShareActivityProvider {
var activities: [Any] { get }
var applicationActivities: [UIActivity] { get }
}

View File

@ -10,6 +10,8 @@ import SafariServices
import CoreDataStack
import MastodonSDK
import PanModal
import MastodonAsset
import MastodonLocalization
final public class SceneCoordinator {
@ -43,7 +45,7 @@ final public class SceneCoordinator {
return Just(nil).eraseToAnyPublisher()
}
let accessToken = pushNotification._accessToken // use raw accessToken value without normalize
let accessToken = pushNotification.accessToken // use raw accessToken value without normalize
if currentActiveAuthenticationBox.userAuthorization.accessToken == accessToken {
// do nothing if notification for current account
return Just(pushNotification).eraseToAnyPublisher()
@ -182,6 +184,8 @@ extension SceneCoordinator {
// report
case report(viewModel: ReportViewModel)
case reportSupplementary(viewModel: ReportSupplementaryViewModel)
case reportResult(viewModel: ReportResultViewModel)
// suggestion account
case suggestionAccount(viewModel: SuggestionAccountViewModel)
@ -194,10 +198,6 @@ extension SceneCoordinator {
case alertController(alertController: UIAlertController)
case activityViewController(activityViewController: UIActivityViewController, sourceView: UIView?, barButtonItem: UIBarButtonItem?)
#if DEBUG
case publicTimeline
#endif
var isOnboarding: Bool {
switch self {
case .welcome,
@ -211,7 +211,7 @@ extension SceneCoordinator {
return false
}
}
}
} // end enum Scene { }
}
extension SceneCoordinator {
@ -266,6 +266,7 @@ extension SceneCoordinator {
}
@discardableResult
@MainActor
func present(scene: Scene, from sender: UIViewController?, transition: Transition) -> UIViewController? {
guard let viewController = get(scene: scene) else {
return nil
@ -442,6 +443,18 @@ private extension SceneCoordinator {
let _viewController = FollowingListViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .report(let viewModel):
let _viewController = ReportViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .reportSupplementary(let viewModel):
let _viewController = ReportSupplementaryViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .reportResult(let viewModel):
let _viewController = ReportResultViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .suggestionAccount(let viewModel):
let _viewController = SuggestionAccountViewController()
_viewController.viewModel = viewModel
@ -477,16 +490,6 @@ private extension SceneCoordinator {
let _viewController = SettingsViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .report(let viewModel):
let _viewController = ReportViewController()
_viewController.viewModel = viewModel
viewController = _viewController
#if DEBUG
case .publicTimeline:
let _viewController = PublicTimelineViewController()
_viewController.viewModel = PublicTimelineViewModel(context: appContext)
viewController = _viewController
#endif
}
setupDependency(for: viewController as? NeedsDependency)

View File

@ -1,145 +0,0 @@
//
// PickServerCategoriesCell.swift
// Mastodon
//
// Created by BradGao on 2021/2/23.
//
//import os.log
//import UIKit
//import MastodonSDK
//
//protocol PickServerCategoriesCellDelegate: AnyObject {
// func pickServerCategoriesCell(_ cell: PickServerCategoriesCell, collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
//}
//
//final class PickServerCategoriesCell: UITableViewCell {
//
// weak var delegate: PickServerCategoriesCellDelegate?
//
// var diffableDataSource: UICollectionViewDiffableDataSource<CategoryPickerSection, CategoryPickerItem>?
//
// let metricView = UIView()
//
// let collectionView: UICollectionView = {
// let flowLayout = UICollectionViewFlowLayout()
// flowLayout.scrollDirection = .horizontal
// let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout)
// view.register(PickServerCategoryCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: PickServerCategoryCollectionViewCell.self))
// view.backgroundColor = .clear
// view.showsHorizontalScrollIndicator = false
// view.showsVerticalScrollIndicator = false
// view.layer.masksToBounds = false
// view.translatesAutoresizingMaskIntoConstraints = false
// return view
// }()
//
// override func prepareForReuse() {
// super.prepareForReuse()
//
// delegate = nil
// }
//
// override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
// super.init(style: style, reuseIdentifier: reuseIdentifier)
// _init()
// }
//
// required init?(coder: NSCoder) {
// super.init(coder: coder)
// _init()
// }
//}
//
//extension PickServerCategoriesCell {
//
// private func _init() {
// selectionStyle = .none
// backgroundColor = Asset.Theme.Mastodon.systemGroupedBackground.color
// configureMargin()
//
// metricView.translatesAutoresizingMaskIntoConstraints = false
// contentView.addSubview(metricView)
// NSLayoutConstraint.activate([
// metricView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
// metricView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
// metricView.topAnchor.constraint(equalTo: contentView.topAnchor),
// metricView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
// metricView.heightAnchor.constraint(equalToConstant: 80).priority(.defaultHigh),
// ])
//
// contentView.addSubview(collectionView)
// NSLayoutConstraint.activate([
// collectionView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
// collectionView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
// collectionView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
// contentView.bottomAnchor.constraint(equalTo: collectionView.bottomAnchor, constant: 20),
// collectionView.heightAnchor.constraint(equalToConstant: 80).priority(.defaultHigh),
// ])
//
// collectionView.delegate = self
// }
//
// override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
// super.traitCollectionDidChange(previousTraitCollection)
//
// configureMargin()
// }
//
// override func layoutSubviews() {
// super.layoutSubviews()
//
// collectionView.collectionViewLayout.invalidateLayout()
// }
//
//}
//
//extension PickServerCategoriesCell {
// private func configureMargin() {
// switch traitCollection.horizontalSizeClass {
// case .regular:
// let margin = MastodonPickServerViewController.viewEdgeMargin
// contentView.layoutMargins = UIEdgeInsets(top: 0, left: margin, bottom: 0, right: margin)
// default:
// contentView.layoutMargins = .zero
// }
// }
//}
//
//// MARK: - UICollectionViewDelegateFlowLayout
//extension PickServerCategoriesCell: UICollectionViewDelegateFlowLayout {
//
// func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: indexPath: %s", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription)
// collectionView.selectItem(at: indexPath, animated: true, scrollPosition: .centeredHorizontally)
// delegate?.pickServerCategoriesCell(self, collectionView: collectionView, didSelectItemAt: indexPath)
// }
//
// func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
// layoutIfNeeded()
// return UIEdgeInsets(top: 0, left: metricView.frame.minX - collectionView.frame.minX, bottom: 0, right: collectionView.frame.maxX - metricView.frame.maxX)
// }
//
// func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
// return 16
// }
//
// func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
// return CGSize(width: 60, height: 80)
// }
//
//}
//
//extension PickServerCategoriesCell {
//
// override func accessibilityElementCount() -> Int {
// guard let diffableDataSource = diffableDataSource else { return 0 }
// return diffableDataSource.snapshot().itemIdentifiers.count
// }
//
// override func accessibilityElement(at index: Int) -> Any? {
// guard let item = collectionView.cellForItem(at: IndexPath(item: index, section: 0)) else { return nil }
// return item
// }
//
//}

View File

@ -1,171 +0,0 @@
//
// PickServerSearchCell.swift
// Mastodon
//
// Created by BradGao on 2021/2/24.
//
import UIKit
//protocol PickServerSearchCellDelegate: AnyObject {
// func pickServerSearchCell(_ cell: PickServerSearchCell, searchTextDidChange searchText: String?)
//}
//
//class PickServerSearchCell: UITableViewCell {
//
// weak var delegate: PickServerSearchCellDelegate?
//
// private var bgView: UIView = {
// let view = UIView()
// view.backgroundColor = Asset.Theme.Mastodon.secondaryGroupedSystemBackground.color
// view.translatesAutoresizingMaskIntoConstraints = false
// view.layer.maskedCorners = [
// .layerMinXMinYCorner,
// .layerMaxXMinYCorner
// ]
// view.layer.cornerCurve = .continuous
// view.layer.cornerRadius = MastodonPickServerAppearance.tableViewCornerRadius
// return view
// }()
//
// private var textFieldBgView: UIView = {
// let view = UIView()
// view.backgroundColor = Asset.Colors.TextField.background.color
// view.translatesAutoresizingMaskIntoConstraints = false
// view.layer.masksToBounds = true
// view.layer.cornerRadius = 6
// view.layer.cornerCurve = .continuous
// return view
// }()
//
// let searchTextField: UITextField = {
// let textField = UITextField()
// textField.translatesAutoresizingMaskIntoConstraints = false
// textField.leftView = {
// let imageView = UIImageView(
// image: UIImage(
// systemName: "magnifyingglass",
// withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .regular)
// )
// )
// imageView.tintColor = Asset.Colors.Label.secondary.color.withAlphaComponent(0.6)
//
// let containerView = UIView()
// imageView.translatesAutoresizingMaskIntoConstraints = false
// containerView.addSubview(imageView)
// NSLayoutConstraint.activate([
// imageView.topAnchor.constraint(equalTo: containerView.topAnchor),
// imageView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
// imageView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
// ])
//
// let paddingView = UIView()
// paddingView.translatesAutoresizingMaskIntoConstraints = false
// containerView.addSubview(paddingView)
// NSLayoutConstraint.activate([
// paddingView.topAnchor.constraint(equalTo: containerView.topAnchor),
// paddingView.leadingAnchor.constraint(equalTo: imageView.trailingAnchor),
// paddingView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
// paddingView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
// paddingView.widthAnchor.constraint(equalToConstant: 4).priority(.defaultHigh),
// ])
// return containerView
// }()
// textField.leftViewMode = .always
// textField.font = .systemFont(ofSize: 15, weight: .regular)
// textField.tintColor = Asset.Colors.Label.primary.color
// textField.textColor = Asset.Colors.Label.primary.color
// textField.adjustsFontForContentSizeCategory = true
// textField.attributedPlaceholder =
// NSAttributedString(string: L10n.Scene.ServerPicker.Input.placeholder,
// attributes: [.font: UIFont.systemFont(ofSize: 15, weight: .regular),
// .foregroundColor: Asset.Colors.Label.secondary.color.withAlphaComponent(0.6)])
// textField.clearButtonMode = .whileEditing
// textField.autocapitalizationType = .none
// textField.autocorrectionType = .no
// textField.returnKeyType = .done
// textField.keyboardType = .URL
// return textField
// }()
//
// override func prepareForReuse() {
// super.prepareForReuse()
//
// delegate = nil
// }
//
// override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
// super.init(style: style, reuseIdentifier: reuseIdentifier)
// _init()
// }
//
// required init?(coder: NSCoder) {
// super.init(coder: coder)
// _init()
// }
//}
//
//extension PickServerSearchCell {
// private func _init() {
// selectionStyle = .none
// backgroundColor = Asset.Theme.Mastodon.systemGroupedBackground.color
// configureMargin()
//
// searchTextField.addTarget(self, action: #selector(textFieldDidChange(_:)), for: .editingChanged)
// searchTextField.delegate = self
//
// contentView.addSubview(bgView)
// contentView.addSubview(textFieldBgView)
// contentView.addSubview(searchTextField)
//
// NSLayoutConstraint.activate([
// bgView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
// bgView.topAnchor.constraint(equalTo: contentView.topAnchor),
// bgView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
// bgView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
//
// textFieldBgView.leadingAnchor.constraint(equalTo: bgView.leadingAnchor, constant: 14),
// textFieldBgView.topAnchor.constraint(equalTo: bgView.topAnchor, constant: 12),
// bgView.trailingAnchor.constraint(equalTo: textFieldBgView.trailingAnchor, constant: 14),
// bgView.bottomAnchor.constraint(equalTo: textFieldBgView.bottomAnchor, constant: 13),
//
// searchTextField.leadingAnchor.constraint(equalTo: textFieldBgView.leadingAnchor, constant: 11),
// searchTextField.topAnchor.constraint(equalTo: textFieldBgView.topAnchor, constant: 4),
// textFieldBgView.trailingAnchor.constraint(equalTo: searchTextField.trailingAnchor, constant: 11),
// textFieldBgView.bottomAnchor.constraint(equalTo: searchTextField.bottomAnchor, constant: 4),
// ])
// }
//
// override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
// super.traitCollectionDidChange(previousTraitCollection)
//
// configureMargin()
// }
//}
//
//extension PickServerSearchCell {
// private func configureMargin() {
// switch traitCollection.horizontalSizeClass {
// case .regular:
// let margin = MastodonPickServerViewController.viewEdgeMargin
// contentView.layoutMargins = UIEdgeInsets(top: 0, left: margin, bottom: 0, right: margin)
// default:
// contentView.layoutMargins = .zero
// }
// }
//}
//
//extension PickServerSearchCell {
// @objc private func textFieldDidChange(_ textField: UITextField) {
// delegate?.pickServerSearchCell(self, searchTextDidChange: textField.text)
// }
//}
//
//// MARK: - UITextFieldDelegate
//extension PickServerSearchCell: UITextFieldDelegate {
//
// func textFieldShouldReturn(_ textField: UITextField) -> Bool {
// textField.resignFirstResponder()
// return false
// }
//}

View File

@ -7,32 +7,9 @@
import CoreData
import Foundation
import CoreDataStack
enum SelectedAccountItem {
case accountObjectID(accountObjectID: NSManagedObjectID)
enum SelectedAccountItem: Hashable {
case account(ManagedObjectRecord<MastodonUser>)
case placeHolder(uuid: UUID)
}
extension SelectedAccountItem: Equatable {
static func == (lhs: SelectedAccountItem, rhs: SelectedAccountItem) -> Bool {
switch (lhs, rhs) {
case (.accountObjectID(let idLeft), .accountObjectID(let idRight)):
return idLeft == idRight
case (.placeHolder(let uuidLeft), .placeHolder(let uuidRight)):
return uuidLeft == uuidRight
default:
return false
}
}
}
extension SelectedAccountItem: Hashable {
func hash(into hasher: inout Hasher) {
switch self {
case .accountObjectID(let id):
hasher.combine(id)
case .placeHolder(let id):
hasher.combine(id.uuidString)
}
}
}

View File

@ -17,15 +17,17 @@ enum SelectedAccountSection: Equatable, Hashable {
extension SelectedAccountSection {
static func collectionViewDiffableDataSource(
for collectionView: UICollectionView,
managedObjectContext: NSManagedObjectContext
collectionView: UICollectionView,
context: AppContext
) -> UICollectionViewDiffableDataSource<SelectedAccountSection, SelectedAccountItem> {
UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item -> UICollectionViewCell? in
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: SuggestionAccountCollectionViewCell.self), for: indexPath) as! SuggestionAccountCollectionViewCell
switch item {
case .accountObjectID(let objectID):
let user = managedObjectContext.object(with: objectID) as! MastodonUser
cell.config(with: user)
case .account(let record):
context.managedObjectContext.performAndWait {
guard let user = record.object(in: context.managedObjectContext) else { return }
cell.config(with: user)
}
case .placeHolder:
cell.configAsPlaceHolder()
}

View File

@ -8,6 +8,8 @@
import UIKit
import MastodonSDK
import MastodonMeta
import MastodonAsset
import MastodonLocalization
enum AutoCompleteSection: Equatable, Hashable {
case main
@ -80,7 +82,7 @@ extension AutoCompleteSection {
}
cell.subtitleLabel.text = "@" + account.acct
cell.avatarImageView.isHidden = false
cell.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: URL(string: account.avatar)))
cell.avatarImageView.configure(configuration: .init(url: URL(string: account.avatar)))
}
private static func configureEmoji(cell: AutoCompleteTableViewCell, emoji: Mastodon.Entity.Emoji, isFirst: Bool) {
@ -90,7 +92,7 @@ extension AutoCompleteSection {
// cell.subtitleLabel.text = isFirst ? L10n.Scene.Compose.AutoComplete.spaceToAdd : " "
cell.subtitleLabel.text = " "
cell.avatarImageView.isHidden = false
cell.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: URL(string: emoji.url)))
cell.avatarImageView.configure(configuration: .init(url: URL(string: emoji.url)))
}
}

View File

@ -9,11 +9,12 @@ import Foundation
import Combine
import CoreData
import MastodonMeta
import CoreDataStack
/// Note: update Equatable when change case
enum ComposeStatusItem {
case replyTo(statusObjectID: NSManagedObjectID)
case input(replyToStatusObjectID: NSManagedObjectID?, attribute: ComposeStatusAttribute)
case replyTo(record: ManagedObjectRecord<Status>)
case input(replyTo: ManagedObjectRecord<Status>?, attribute: ComposeStatusAttribute)
case attachment(attachmentAttribute: ComposeStatusAttachmentAttribute)
case pollOption(pollOptionAttributes: [ComposeStatusPollItem.PollOptionAttribute], pollExpiresOptionAttribute: ComposeStatusPollItem.PollExpiresOptionAttribute)
}
@ -21,26 +22,21 @@ enum ComposeStatusItem {
extension ComposeStatusItem: Hashable { }
extension ComposeStatusItem {
final class ComposeStatusAttribute: Equatable, Hashable {
final class ComposeStatusAttribute: Hashable {
private let id = UUID()
let avatarURL = CurrentValueSubject<URL?, Never>(nil)
let displayName = CurrentValueSubject<String?, Never>(nil)
let emojiMeta = CurrentValueSubject<MastodonContent.Emojis, Never>([:])
let username = CurrentValueSubject<String?, Never>(nil)
let composeContent = CurrentValueSubject<String?, Never>(nil)
let isContentWarningComposing = CurrentValueSubject<Bool, Never>(false)
let contentWarningContent = CurrentValueSubject<String, Never>("")
@Published var author: ManagedObjectRecord<MastodonUser>?
@Published var composeContent: String?
@Published var isContentWarningComposing = false
@Published var contentWarningContent = ""
static func == (lhs: ComposeStatusAttribute, rhs: ComposeStatusAttribute) -> Bool {
return lhs.avatarURL.value == rhs.avatarURL.value &&
lhs.displayName.value == rhs.displayName.value &&
lhs.emojiMeta.value == rhs.emojiMeta.value &&
lhs.username.value == rhs.username.value &&
lhs.composeContent.value == rhs.composeContent.value &&
lhs.isContentWarningComposing.value == rhs.isContentWarningComposing.value &&
lhs.contentWarningContent.value == rhs.contentWarningContent.value
return lhs.author == rhs.author
&& lhs.composeContent == rhs.composeContent
&& lhs.isContentWarningComposing == rhs.isContentWarningComposing
&& lhs.contentWarningContent == rhs.contentWarningContent
}
func hash(into hasher: inout Hasher) {

View File

@ -7,6 +7,8 @@
import Foundation
import Combine
import MastodonAsset
import MastodonLocalization
enum ComposeStatusPollItem {
case pollOption(attribute: PollOptionAttribute)

View File

@ -14,7 +14,7 @@ import MastodonMeta
import AlamofireImage
enum ComposeStatusSection: Equatable, Hashable {
case repliedTo
case replyTo
case status
case attachment
case poll
@ -24,43 +24,44 @@ extension ComposeStatusSection {
enum ComposeKind {
case post
case hashtag(hashtag: String)
case mention(mastodonUserObjectID: NSManagedObjectID)
case reply(repliedToStatusObjectID: NSManagedObjectID)
case mention(user: ManagedObjectRecord<MastodonUser>)
case reply(status: ManagedObjectRecord<Status>)
}
}
extension ComposeStatusSection {
static func configureStatusContent(
static func configure(
cell: ComposeStatusContentTableViewCell,
attribute: ComposeStatusItem.ComposeStatusAttribute
) {
// set avatar
attribute.avatarURL
.receive(on: DispatchQueue.main)
.sink { avatarURL in
cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: avatarURL))
}
.store(in: &cell.disposeBag)
// set display name and username
Publishers.CombineLatest3(
attribute.displayName,
attribute.emojiMeta,
attribute.username
)
.receive(on: DispatchQueue.main)
.sink { displayName, emojiMeta, username in
do {
let mastodonContent = MastodonContent(content: displayName ?? " ", emojis: emojiMeta)
let metaContent = try MastodonMetaContent.convert(document: mastodonContent)
cell.statusView.nameLabel.configure(content: metaContent)
} catch {
let metaContent = PlaintextMetaContent(string: " ")
cell.statusView.nameLabel.configure(content: metaContent)
}
cell.statusView.usernameLabel.text = username.flatMap { "@" + $0 } ?? " "
}
.store(in: &cell.disposeBag)
// cell.prepa
// // set avatar
// attribute.avatarURL
// .receive(on: DispatchQueue.main)
// .sink { avatarURL in
// cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: avatarURL))
// }
// .store(in: &cell.disposeBag)
// // set display name and username
// Publishers.CombineLatest3(
// attribute.displayName,
// attribute.emojiMeta,
// attribute.username
// )
// .receive(on: DispatchQueue.main)
// .sink { displayName, emojiMeta, username in
// do {
// let mastodonContent = MastodonContent(content: displayName ?? " ", emojis: emojiMeta)
// let metaContent = try MastodonMetaContent.convert(document: mastodonContent)
// cell.statusView.nameLabel.configure(content: metaContent)
// } catch {
// let metaContent = PlaintextMetaContent(string: " ")
// cell.statusView.nameLabel.configure(content: metaContent)
// }
// cell.statusView.usernameLabel.text = username.flatMap { "@" + $0 } ?? " "
// }
// .store(in: &cell.disposeBag)
}
}

View File

@ -0,0 +1,90 @@
//
// FeedFetchedResultsController.swift
// FeedFetchedResultsController
//
// Created by Cirno MainasuK on 2021-8-19.
// Copyright © 2021 Twidere. All rights reserved.
//
import os.log
import Foundation
import UIKit
import Combine
import CoreData
import CoreDataStack
import MastodonSDK
final public class FeedFetchedResultsController: NSObject {
public let logger = Logger(subsystem: "FeedFetchedResultsController", category: "DB")
var disposeBag = Set<AnyCancellable>()
public let fetchedResultsController: NSFetchedResultsController<Feed>
// input
@Published public var predicate = Feed.predicate(kind: .none, acct: .none)
// output
private let _objectIDs = PassthroughSubject<[NSManagedObjectID], Never>()
@Published public var records: [ManagedObjectRecord<Feed>] = []
public init(managedObjectContext: NSManagedObjectContext) {
self.fetchedResultsController = {
let fetchRequest = Feed.sortedFetchRequest
// make sure initial query return empty results
fetchRequest.returnsObjectsAsFaults = false
fetchRequest.shouldRefreshRefetchedObjects = true
fetchRequest.fetchBatchSize = 15
let controller = NSFetchedResultsController(
fetchRequest: fetchRequest,
managedObjectContext: managedObjectContext,
sectionNameKeyPath: nil,
cacheName: nil
)
return controller
}()
super.init()
// debounce output to prevent UI update issues
_objectIDs
.throttle(for: 0.1, scheduler: DispatchQueue.main, latest: true)
.map { objectIDs in objectIDs.map { ManagedObjectRecord(objectID: $0) } }
.assign(to: &$records)
fetchedResultsController.delegate = self
$predicate
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { [weak self] predicate in
guard let self = self else { return }
self.fetchedResultsController.fetchRequest.predicate = predicate
do {
try self.fetchedResultsController.performFetch()
} catch {
assertionFailure(error.localizedDescription)
}
}
.store(in: &disposeBag)
}
deinit {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
}
}
// MARK: - NSFetchedResultsControllerDelegate
extension FeedFetchedResultsController: NSFetchedResultsControllerDelegate {
public func controller(
_ controller: NSFetchedResultsController<NSFetchRequestResult>,
didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference
) {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
let snapshot = snapshot as NSDiffableDataSourceSnapshot<String, NSManagedObjectID>
self._objectIDs.send(snapshot.itemIdentifiers)
}
}

View File

@ -21,8 +21,9 @@ final class SearchHistoryFetchedResultController: NSObject {
let userID = CurrentValueSubject<Mastodon.Entity.Status.ID?, Never>(nil)
// output
let objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([])
let _objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([])
@Published var records: [ManagedObjectRecord<SearchHistory>] = []
init(managedObjectContext: NSManagedObjectContext) {
self.fetchedResultsController = {
let fetchRequest = SearchHistory.sortedFetchRequest
@ -38,12 +39,18 @@ final class SearchHistoryFetchedResultController: NSObject {
return controller
}()
super.init()
// debounce output to prevent UI update issues
_objectIDs
.throttle(for: 0.1, scheduler: DispatchQueue.main, latest: true)
.map { objectIDs in objectIDs.map { ManagedObjectRecord(objectID: $0) } }
.assign(to: &$records)
fetchedResultsController.delegate = self
Publishers.CombineLatest(
self.domain.removeDuplicates(),
self.userID.removeDuplicates()
self.domain,
self.userID
)
.receive(on: DispatchQueue.main)
.sink { [weak self] domain, userID in
@ -67,6 +74,6 @@ extension SearchHistoryFetchedResultController: NSFetchedResultsControllerDelega
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
let objects = fetchedResultsController.fetchedObjects ?? []
self.objectIDs.value = objects.map { $0.objectID }
self._objectIDs.value = objects.map { $0.objectID }
}
}

View File

@ -11,6 +11,7 @@ import Combine
import CoreData
import CoreDataStack
import MastodonSDK
import MastodonUI
final class StatusFetchedResultsController: NSObject {
@ -23,7 +24,8 @@ final class StatusFetchedResultsController: NSObject {
let statusIDs = CurrentValueSubject<[Mastodon.Entity.Status.ID], Never>([])
// output
let objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([])
let _objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([])
@Published var records: [ManagedObjectRecord<Status>] = []
init(managedObjectContext: NSManagedObjectContext, domain: String?, additionalTweetPredicate: NSPredicate?) {
self.domain.value = domain ?? ""
@ -43,11 +45,17 @@ final class StatusFetchedResultsController: NSObject {
}()
super.init()
// debounce output to prevent UI update issues
_objectIDs
.throttle(for: 0.1, scheduler: DispatchQueue.main, latest: true)
.map { objectIDs in objectIDs.map { ManagedObjectRecord(objectID: $0) } }
.assign(to: &$records)
fetchedResultsController.delegate = self
Publishers.CombineLatest(
self.domain.removeDuplicates().eraseToAnyPublisher(),
self.statusIDs.removeDuplicates().eraseToAnyPublisher()
self.domain.removeDuplicates(),
self.statusIDs.removeDuplicates()
)
.receive(on: DispatchQueue.main)
.sink { [weak self] domain, ids in
@ -68,6 +76,18 @@ final class StatusFetchedResultsController: NSObject {
}
extension StatusFetchedResultsController {
public func append(statusIDs: [Mastodon.Entity.Status.ID]) {
var result = self.statusIDs.value
for statusID in statusIDs where !result.contains(statusID) {
result.append(statusID)
}
self.statusIDs.value = result
}
}
// MARK: - NSFetchedResultsControllerDelegate
extension StatusFetchedResultsController: NSFetchedResultsControllerDelegate {
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
@ -82,6 +102,6 @@ extension StatusFetchedResultsController: NSFetchedResultsControllerDelegate {
}
.sorted { $0.0 < $1.0 }
.map { $0.1.objectID }
self.objectIDs.value = items
self._objectIDs.value = items
}
}

View File

@ -11,6 +11,7 @@ import Combine
import CoreData
import CoreDataStack
import MastodonSDK
import MastodonUI
final class UserFetchedResultsController: NSObject {
@ -19,14 +20,15 @@ final class UserFetchedResultsController: NSObject {
let fetchedResultsController: NSFetchedResultsController<MastodonUser>
// input
let domain = CurrentValueSubject<String?, Never>(nil)
let userIDs = CurrentValueSubject<[Mastodon.Entity.Account.ID], Never>([])
@Published var domain: String? = nil
@Published var userIDs: [Mastodon.Entity.Account.ID] = []
// output
let objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([])
let _objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([])
@Published var records: [ManagedObjectRecord<MastodonUser>] = []
init(managedObjectContext: NSManagedObjectContext, domain: String?, additionalTweetPredicate: NSPredicate?) {
self.domain.value = domain ?? ""
self.domain = domain ?? ""
self.fetchedResultsController = {
let fetchRequest = MastodonUser.sortedFetchRequest
fetchRequest.predicate = MastodonUser.predicate(domain: domain ?? "", ids: [])
@ -42,12 +44,18 @@ final class UserFetchedResultsController: NSObject {
return controller
}()
super.init()
// debounce output to prevent UI update issues
_objectIDs
.throttle(for: 0.1, scheduler: DispatchQueue.main, latest: true)
.map { objectIDs in objectIDs.map { ManagedObjectRecord(objectID: $0) } }
.assign(to: &$records)
fetchedResultsController.delegate = self
Publishers.CombineLatest(
self.domain.removeDuplicates().eraseToAnyPublisher(),
self.userIDs.removeDuplicates().eraseToAnyPublisher()
self.$domain.removeDuplicates(),
self.$userIDs.removeDuplicates()
)
.receive(on: DispatchQueue.main)
.sink { [weak self] domain, ids in
@ -68,12 +76,24 @@ final class UserFetchedResultsController: NSObject {
}
extension UserFetchedResultsController {
public func append(userIDs: [Mastodon.Entity.Account.ID]) {
var result = self.userIDs
for userID in userIDs where !result.contains(userID) {
result.append(userID)
}
self.userIDs = result
}
}
// MARK: - NSFetchedResultsControllerDelegate
extension UserFetchedResultsController: NSFetchedResultsControllerDelegate {
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
let indexes = userIDs.value
let indexes = userIDs
let objects = fetchedResultsController.fetchedObjects ?? []
let items: [NSManagedObjectID] = objects
@ -82,6 +102,6 @@ extension UserFetchedResultsController: NSFetchedResultsControllerDelegate {
}
.sorted { $0.0 < $1.0 }
.map { $0.1.objectID }
self.objectIDs.value = items
self._objectIDs.value = items
}
}

View File

@ -7,50 +7,10 @@
import CoreData
import Foundation
import CoreDataStack
enum NotificationItem {
case notification(objectID: NSManagedObjectID, attribute: Item.StatusAttribute)
case notificationStatus(objectID: NSManagedObjectID, attribute: Item.StatusAttribute) // display notification status without card wrapper
enum NotificationItem: Hashable {
case feed(record: ManagedObjectRecord<Feed>)
case feedLoader(record: ManagedObjectRecord<Feed>)
case bottomLoader
}
extension NotificationItem: Equatable {
static func == (lhs: NotificationItem, rhs: NotificationItem) -> Bool {
switch (lhs, rhs) {
case (.notification(let idLeft, _), .notification(let idRight, _)):
return idLeft == idRight
case (.notificationStatus(let idLeft, _), .notificationStatus(let idRight, _)):
return idLeft == idRight
case (.bottomLoader, .bottomLoader):
return true
default:
return false
}
}
}
extension NotificationItem: Hashable {
func hash(into hasher: inout Hasher) {
switch self {
case .notification(let id, _):
hasher.combine(id)
case .notificationStatus(let id, _):
hasher.combine(id)
case .bottomLoader:
hasher.combine(String(describing: NotificationItem.bottomLoader.self))
}
}
}
extension NotificationItem {
var statusObjectItem: StatusObjectItem? {
switch self {
case .notification(let objectID, _):
return .mastodonNotification(objectID: objectID)
case .notificationStatus(let objectID, _):
return .mastodonNotification(objectID: objectID)
case .bottomLoader:
return nil
}
}
}

View File

@ -13,234 +13,292 @@ import MastodonSDK
import UIKit
import MetaTextKit
import MastodonMeta
import MastodonAsset
import MastodonLocalization
enum NotificationSection: Equatable, Hashable {
case main
}
extension NotificationSection {
static func tableViewDiffableDataSource(
for tableView: UITableView,
dependency: NeedsDependency,
managedObjectContext: NSManagedObjectContext,
delegate: NotificationTableViewCellDelegate,
statusTableViewCellDelegate: StatusTableViewCellDelegate
struct Configuration {
weak var notificationTableViewCellDelegate: NotificationTableViewCellDelegate?
}
static func diffableDataSource(
tableView: UITableView,
context: AppContext,
configuration: Configuration
) -> UITableViewDiffableDataSource<NotificationSection, NotificationItem> {
UITableViewDiffableDataSource(tableView: tableView) {
[weak delegate, weak dependency]
(tableView, indexPath, notificationItem) -> UITableViewCell? in
guard let dependency = dependency else { return nil }
switch notificationItem {
case .notification(let objectID, let attribute):
guard let notification = try? managedObjectContext.existingObject(with: objectID) as? MastodonNotification,
!notification.isDeleted
else { return UITableViewCell() }
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: NotificationStatusTableViewCell.self), for: indexPath) as! NotificationStatusTableViewCell
configure(
tableView: tableView,
cell: cell,
notification: notification,
dependency: dependency,
attribute: attribute
)
cell.delegate = delegate
cell.isAccessibilityElement = true
NotificationSection.configureStatusAccessibilityLabel(cell: cell)
tableView.register(NotificationTableViewCell.self, forCellReuseIdentifier: String(describing: NotificationTableViewCell.self))
tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
return UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in
switch item {
case .feed(let record):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: NotificationTableViewCell.self), for: indexPath) as! NotificationTableViewCell
context.managedObjectContext.performAndWait {
guard let feed = record.object(in: context.managedObjectContext) else { return }
configure(
context: context,
tableView: tableView,
cell: cell,
viewModel: NotificationTableViewCell.ViewModel(value: .feed(feed)),
configuration: configuration
)
}
return cell
case .notificationStatus(objectID: let objectID, attribute: let attribute):
guard let notification = try? managedObjectContext.existingObject(with: objectID) as? MastodonNotification,
!notification.isDeleted,
let status = notification.status,
let requestUserID = dependency.context.authenticationService.activeMastodonAuthenticationBox.value?.userID
else { return UITableViewCell() }
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell
// configure cell
StatusSection.configureStatusTableViewCell(
cell: cell,
tableView: tableView,
timelineContext: .notifications,
dependency: dependency,
readableLayoutFrame: tableView.readableContentGuide.layoutFrame,
status: status,
requestUserID: requestUserID,
statusItemAttribute: attribute
)
cell.statusView.headerContainerView.isHidden = true // set header hide
cell.statusView.actionToolbarContainer.isHidden = true // set toolbar hide
cell.statusView.actionToolbarPlaceholderPaddingView.isHidden = false
cell.delegate = statusTableViewCellDelegate
cell.isAccessibilityElement = true
StatusSection.configureStatusAccessibilityLabel(cell: cell)
case .feedLoader:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell
cell.activityIndicatorView.startAnimating()
return cell
case .bottomLoader:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) as! TimelineBottomLoaderTableViewCell
cell.startAnimating()
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell
cell.activityIndicatorView.startAnimating()
return cell
}
// switch notificationItem {
// case .notification(let objectID, let attribute):
// guard let notification = try? managedObjectContext.existingObject(with: objectID) as? MastodonNotification,
// !notification.isDeleted
// else { return UITableViewCell() }
//
// let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: NotificationStatusTableViewCell.self), for: indexPath) as! NotificationStatusTableViewCell
// configure(
// tableView: tableView,
// cell: cell,
// notification: notification,
// dependency: dependency,
// attribute: attribute
// )
// cell.delegate = delegate
// cell.isAccessibilityElement = true
// NotificationSection.configureStatusAccessibilityLabel(cell: cell)
// return cell
//
// case .notificationStatus(objectID: let objectID, attribute: let attribute):
// guard let notification = try? managedObjectContext.existingObject(with: objectID) as? MastodonNotification,
// !notification.isDeleted,
// let status = notification.status,
// let requestUserID = dependency.context.authenticationService.activeMastodonAuthenticationBox.value?.userID
// else { return UITableViewCell() }
// let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell
//
// // configure cell
// StatusSection.configureStatusTableViewCell(
// cell: cell,
// tableView: tableView,
// timelineContext: .notifications,
// dependency: dependency,
// readableLayoutFrame: tableView.readableContentGuide.layoutFrame,
// status: status,
// requestUserID: requestUserID,
// statusItemAttribute: attribute
// )
// cell.statusView.headerContainerView.isHidden = true // set header hide
// cell.statusView.actionToolbarContainer.isHidden = true // set toolbar hide
// cell.statusView.actionToolbarPlaceholderPaddingView.isHidden = false
// cell.delegate = statusTableViewCellDelegate
// cell.isAccessibilityElement = true
// StatusSection.configureStatusAccessibilityLabel(cell: cell)
// return cell
//
// case .bottomLoader:
// let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) as! TimelineBottomLoaderTableViewCell
// cell.startAnimating()
// return cell
// }
}
}
}
extension NotificationSection {
static func configure(
context: AppContext,
tableView: UITableView,
cell: NotificationStatusTableViewCell,
notification: MastodonNotification,
dependency: NeedsDependency,
attribute: Item.StatusAttribute
cell: NotificationTableViewCell,
viewModel: NotificationTableViewCell.ViewModel,
configuration: Configuration
) {
// configure author
cell.configure(
with: AvatarConfigurableViewConfiguration(
avatarImageURL: notification.account.avatarImageURL()
)
StatusSection.setupStatusPollDataSource(
context: context,
statusView: cell.notificationView.statusView
)
func createActionImage() -> UIImage? {
return UIImage(
systemName: notification.notificationType.actionImageName,
withConfiguration: UIImage.SymbolConfiguration(
pointSize: 12, weight: .semibold
)
)?
.withTintColor(.systemBackground)
.af.imageAspectScaled(toFit: CGSize(width: 14, height: 14))
}
StatusSection.setupStatusPollDataSource(
context: context,
statusView: cell.notificationView.quoteStatusView
)
cell.avatarButton.badgeImageView.backgroundColor = notification.notificationType.color
cell.avatarButton.badgeImageView.image = createActionImage()
cell.traitCollectionDidChange
.receive(on: DispatchQueue.main)
.sink { [weak cell] in
guard let cell = cell else { return }
cell.avatarButton.badgeImageView.image = createActionImage()
}
context.authenticationService.activeMastodonAuthenticationBox
.map { $0 as UserIdentifier? }
.assign(to: \.userIdentifier, on: cell.notificationView.viewModel)
.store(in: &cell.disposeBag)
// configure author name, notification description, timestamp
let nameText = notification.account.displayNameWithFallback
let titleLabelText: String = {
switch notification.notificationType {
case .favourite: return L10n.Scene.Notification.userFavoritedYourPost(nameText)
case .follow: return L10n.Scene.Notification.userFollowedYou(nameText)
case .followRequest: return L10n.Scene.Notification.userRequestedToFollowYou(nameText)
case .mention: return L10n.Scene.Notification.userMentionedYou(nameText)
case .poll: return L10n.Scene.Notification.userYourPollHasEnded(nameText)
case .reblog: return L10n.Scene.Notification.userRebloggedYourPost(nameText)
default: return ""
}
}()
do {
let nameContent = MastodonContent(content: nameText, emojis: notification.account.emojiMeta)
let nameMetaContent = try MastodonMetaContent.convert(document: nameContent)
let mastodonContent = MastodonContent(content: titleLabelText, emojis: notification.account.emojiMeta)
let metaContent = try MastodonMetaContent.convert(document: mastodonContent)
cell.titleLabel.configure(content: metaContent)
if let nameRange = metaContent.string.range(of: nameMetaContent.string) {
let nsRange = NSRange(nameRange, in: metaContent.string)
cell.titleLabel.textStorage.addAttributes([
.font: UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold), maximumPointSize: 20),
.foregroundColor: Asset.Colors.brandBlue.color,
], range: nsRange)
}
} catch {
let metaContent = PlaintextMetaContent(string: titleLabelText)
cell.titleLabel.configure(content: metaContent)
}
let createAt = notification.createAt
cell.timestampLabel.text = createAt.localizedSlowedTimeAgoSinceNow
AppContext.shared.timestampUpdatePublisher
.receive(on: DispatchQueue.main)
.sink { [weak cell] _ in
guard let cell = cell else { return }
cell.timestampLabel.text = createAt.localizedSlowedTimeAgoSinceNow
}
.store(in: &cell.disposeBag)
// configure follow request (if exist)
if case .followRequest = notification.notificationType {
cell.acceptButton.publisher(for: .touchUpInside)
.sink { [weak cell] _ in
guard let cell = cell else { return }
cell.delegate?.notificationTableViewCell(cell, notification: notification, acceptButtonDidPressed: cell.acceptButton)
}
.store(in: &cell.disposeBag)
cell.rejectButton.publisher(for: .touchUpInside)
.sink { [weak cell] _ in
guard let cell = cell else { return }
cell.delegate?.notificationTableViewCell(cell, notification: notification, rejectButtonDidPressed: cell.rejectButton)
}
.store(in: &cell.disposeBag)
cell.buttonStackView.isHidden = false
} else {
cell.buttonStackView.isHidden = true
}
// configure status (if exist)
if let status = notification.status {
let frame = CGRect(
x: 0,
y: 0,
width: tableView.readableContentGuide.layoutFrame.width - NotificationStatusTableViewCell.statusPadding.left - NotificationStatusTableViewCell.statusPadding.right,
height: tableView.readableContentGuide.layoutFrame.height
)
StatusSection.configure(
cell: cell,
tableView: tableView,
timelineContext: .notifications,
dependency: dependency,
readableLayoutFrame: frame,
status: status,
requestUserID: notification.userID,
statusItemAttribute: attribute
)
cell.statusContainerView.isHidden = false
cell.containerStackView.alignment = .top
cell.containerStackViewBottomLayoutConstraint.constant = 0
} else {
if case .followRequest = notification.notificationType {
cell.containerStackView.alignment = .top
} else {
cell.containerStackView.alignment = .center
}
cell.statusContainerView.isHidden = true
cell.containerStackViewBottomLayoutConstraint.constant = 5 // 5pt margin when no status view
}
cell.configure(
tableView: tableView,
viewModel: viewModel,
delegate: configuration.notificationTableViewCellDelegate
)
}
static func configureStatusAccessibilityLabel(cell: NotificationStatusTableViewCell) {
// FIXME:
cell.accessibilityLabel = {
var accessibilityViews: [UIView?] = []
accessibilityViews.append(contentsOf: [
cell.titleLabel,
cell.timestampLabel,
cell.statusView
])
if !cell.statusContainerView.isHidden {
if !cell.statusView.headerContainerView.isHidden {
accessibilityViews.append(cell.statusView.headerInfoLabel)
}
accessibilityViews.append(contentsOf: [
cell.statusView.nameMetaLabel,
cell.statusView.dateLabel,
cell.statusView.contentMetaText.textView,
])
}
return accessibilityViews
.compactMap { $0?.accessibilityLabel }
.joined(separator: " ")
}()
}
// static func configure(
// tableView: UITableView,
// cell: NotificationStatusTableViewCell,
// notification: MastodonNotification,
// dependency: NeedsDependency,
// attribute: Item.StatusAttribute
// ) {
// // configure author
// cell.configure(
// with: AvatarConfigurableViewConfiguration(
// avatarImageURL: notification.account.avatarImageURL()
// )
// )
//
// func createActionImage() -> UIImage? {
// return UIImage(
// systemName: notification.notificationType.actionImageName,
// withConfiguration: UIImage.SymbolConfiguration(
// pointSize: 12, weight: .semibold
// )
// )?
// .withTintColor(.systemBackground)
// .af.imageAspectScaled(toFit: CGSize(width: 14, height: 14))
// }
//
// cell.avatarButton.badgeImageView.backgroundColor = notification.notificationType.color
// cell.avatarButton.badgeImageView.image = createActionImage()
// cell.traitCollectionDidChange
// .receive(on: DispatchQueue.main)
// .sink { [weak cell] in
// guard let cell = cell else { return }
// cell.avatarButton.badgeImageView.image = createActionImage()
// }
// .store(in: &cell.disposeBag)
//
// // configure author name, notification description, timestamp
// let nameText = notification.account.displayNameWithFallback
// let titleLabelText: String = {
// switch notification.notificationType {
// case .favourite: return L10n.Scene.Notification.userFavoritedYourPost(nameText)
// case .follow: return L10n.Scene.Notification.userFollowedYou(nameText)
// case .followRequest: return L10n.Scene.Notification.userRequestedToFollowYou(nameText)
// case .mention: return L10n.Scene.Notification.userMentionedYou(nameText)
// case .poll: return L10n.Scene.Notification.userYourPollHasEnded(nameText)
// case .reblog: return L10n.Scene.Notification.userRebloggedYourPost(nameText)
// default: return ""
// }
// }()
//
// do {
// let nameContent = MastodonContent(content: nameText, emojis: notification.account.emojiMeta)
// let nameMetaContent = try MastodonMetaContent.convert(document: nameContent)
//
// let mastodonContent = MastodonContent(content: titleLabelText, emojis: notification.account.emojiMeta)
// let metaContent = try MastodonMetaContent.convert(document: mastodonContent)
//
// cell.titleLabel.configure(content: metaContent)
//
// if let nameRange = metaContent.string.range(of: nameMetaContent.string) {
// let nsRange = NSRange(nameRange, in: metaContent.string)
// cell.titleLabel.textStorage.addAttributes([
// .font: UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold), maximumPointSize: 20),
// .foregroundColor: Asset.Colors.brandBlue.color,
// ], range: nsRange)
// }
//
// } catch {
// let metaContent = PlaintextMetaContent(string: titleLabelText)
// cell.titleLabel.configure(content: metaContent)
// }
//
// let createAt = notification.createAt
// cell.timestampLabel.text = createAt.localizedSlowedTimeAgoSinceNow
// AppContext.shared.timestampUpdatePublisher
// .receive(on: DispatchQueue.main)
// .sink { [weak cell] _ in
// guard let cell = cell else { return }
// cell.timestampLabel.text = createAt.localizedSlowedTimeAgoSinceNow
// }
// .store(in: &cell.disposeBag)
//
// // configure follow request (if exist)
// if case .followRequest = notification.notificationType {
// cell.acceptButton.publisher(for: .touchUpInside)
// .sink { [weak cell] _ in
// guard let cell = cell else { return }
// cell.delegate?.notificationTableViewCell(cell, notification: notification, acceptButtonDidPressed: cell.acceptButton)
// }
// .store(in: &cell.disposeBag)
// cell.rejectButton.publisher(for: .touchUpInside)
// .sink { [weak cell] _ in
// guard let cell = cell else { return }
// cell.delegate?.notificationTableViewCell(cell, notification: notification, rejectButtonDidPressed: cell.rejectButton)
// }
// .store(in: &cell.disposeBag)
// cell.buttonStackView.isHidden = false
// } else {
// cell.buttonStackView.isHidden = true
// }
//
// // configure status (if exist)
// if let status = notification.status {
// let frame = CGRect(
// x: 0,
// y: 0,
// width: tableView.readableContentGuide.layoutFrame.width - NotificationStatusTableViewCell.statusPadding.left - NotificationStatusTableViewCell.statusPadding.right,
// height: tableView.readableContentGuide.layoutFrame.height
// )
// StatusSection.configure(
// cell: cell,
// tableView: tableView,
// timelineContext: .notifications,
// dependency: dependency,
// readableLayoutFrame: frame,
// status: status,
// requestUserID: notification.userID,
// statusItemAttribute: attribute
// )
// cell.statusContainerView.isHidden = false
// cell.containerStackView.alignment = .top
// cell.containerStackViewBottomLayoutConstraint.constant = 0
// } else {
// if case .followRequest = notification.notificationType {
// cell.containerStackView.alignment = .top
// } else {
// cell.containerStackView.alignment = .center
// }
// cell.statusContainerView.isHidden = true
// cell.containerStackViewBottomLayoutConstraint.constant = 5 // 5pt margin when no status view
// }
// }
//
// static func configureStatusAccessibilityLabel(cell: NotificationStatusTableViewCell) {
// // FIXME:
// cell.accessibilityLabel = {
// var accessibilityViews: [UIView?] = []
// accessibilityViews.append(contentsOf: [
// cell.titleLabel,
// cell.timestampLabel,
// cell.statusView
// ])
// if !cell.statusContainerView.isHidden {
// if !cell.statusView.headerContainerView.isHidden {
// accessibilityViews.append(cell.statusView.headerInfoLabel)
// }
// accessibilityViews.append(contentsOf: [
// cell.statusView.nameMetaLabel,
// cell.statusView.dateLabel,
// cell.statusView.contentMetaText.textView,
// ])
// }
// return accessibilityViews
// .compactMap { $0?.accessibilityLabel }
// .joined(separator: " ")
// }()
// }
}

View File

@ -7,6 +7,8 @@
import Foundation
import MastodonSDK
import MastodonAsset
import MastodonLocalization
/// Note: update Equatable when change case
enum CategoryPickerItem {

View File

@ -6,6 +6,8 @@
//
import UIKit
import MastodonAsset
import MastodonLocalization
enum CategoryPickerSection: Equatable, Hashable {
case main

View File

@ -21,7 +21,11 @@ extension PickServerSection {
dependency: NeedsDependency,
pickServerCellDelegate: PickServerCellDelegate
) -> UITableViewDiffableDataSource<PickServerSection, PickServerItem> {
UITableViewDiffableDataSource(tableView: tableView) { [
tableView.register(OnboardingHeadlineTableViewCell.self, forCellReuseIdentifier: String(describing: OnboardingHeadlineTableViewCell.self))
tableView.register(PickServerCell.self, forCellReuseIdentifier: String(describing: PickServerCell.self))
tableView.register(PickServerLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: PickServerLoaderTableViewCell.self))
return UITableViewDiffableDataSource(tableView: tableView) { [
weak dependency,
weak pickServerCellDelegate
] tableView, indexPath, item -> UITableViewCell? in

View File

@ -6,6 +6,8 @@
//
import UIKit
import MastodonAsset
import MastodonLocalization
enum ServerRuleSection: Hashable {
case header

View File

@ -1,68 +0,0 @@
//
// PollItem.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-2.
//
import Foundation
import CoreData
/// Note: update Equatable when change case
enum PollItem {
case option(objectID: NSManagedObjectID, attribute: Attribute)
}
extension PollItem {
class Attribute: Hashable {
enum SelectState: Equatable, Hashable {
case none
case off
case on
}
enum VoteState: Equatable, Hashable {
case hidden
case reveal(voted: Bool, percentage: Double, animated: Bool)
}
var selectState: SelectState
var voteState: VoteState
init(selectState: SelectState, voteState: VoteState) {
self.selectState = selectState
self.voteState = voteState
}
static func == (lhs: PollItem.Attribute, rhs: PollItem.Attribute) -> Bool {
return lhs.selectState == rhs.selectState &&
lhs.voteState == rhs.voteState
}
func hash(into hasher: inout Hasher) {
hasher.combine(selectState)
hasher.combine(voteState)
}
}
}
extension PollItem: Equatable {
static func == (lhs: PollItem, rhs: PollItem) -> Bool {
switch (lhs, rhs) {
case (.option(let objectIDLeft, _), .option(let objectIDRight, _)):
return objectIDLeft == objectIDRight
}
}
}
extension PollItem: Hashable {
func hash(into hasher: inout Hasher) {
switch self {
case .option(let objectID, _):
hasher.combine(objectID)
}
}
}

View File

@ -1,120 +0,0 @@
//
// PollSection.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-2.
//
import UIKit
import CoreData
import CoreDataStack
import MastodonSDK
extension Mastodon.Entity.Attachment: Hashable {
public static func == (lhs: Mastodon.Entity.Attachment, rhs: Mastodon.Entity.Attachment) -> Bool {
return lhs.id == rhs.id
}
public func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
enum PollSection: Equatable, Hashable {
case main
}
extension PollSection {
static func tableViewDiffableDataSource(
for tableView: UITableView,
managedObjectContext: NSManagedObjectContext
) -> UITableViewDiffableDataSource<PollSection, PollItem> {
return UITableViewDiffableDataSource<PollSection, PollItem>(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in
switch item {
case .option(let objectID, let attribute):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PollOptionTableViewCell.self), for: indexPath) as! PollOptionTableViewCell
managedObjectContext.performAndWait {
let option = managedObjectContext.object(with: objectID) as! PollOption
PollSection.configure(cell: cell, pollOption: option, pollItemAttribute: attribute)
cell.isAccessibilityElement = true
cell.accessibilityLabel = {
var labels: [String] = [option.title]
if let percentage = cell.pollOptionView.optionPercentageLabel.text {
labels.append(percentage)
}
return labels.joined(separator: ",")
}()
}
return cell
}
}
}
}
extension PollSection {
static func configure(
cell: PollOptionTableViewCell,
pollOption option: PollOption,
pollItemAttribute attribute: PollItem.Attribute
) {
cell.pollOptionView.optionTextField.text = option.title
configure(cell: cell, selectState: attribute.selectState)
configure(cell: cell, voteState: attribute.voteState)
cell.attribute = attribute
cell.layoutIfNeeded()
cell.updateTextAppearance()
}
}
extension PollSection {
static func configure(cell: PollOptionTableViewCell, selectState state: PollItem.Attribute.SelectState) {
switch state {
case .none:
cell.pollOptionView.checkmarkBackgroundView.isHidden = true
cell.pollOptionView.checkmarkImageView.isHidden = true
case .off:
ThemeService.shared.currentTheme
.receive(on: DispatchQueue.main)
.sink { [weak cell] theme in
guard let cell = cell else { return }
cell.pollOptionView.checkmarkBackgroundView.backgroundColor = theme.tertiarySystemBackgroundColor
cell.pollOptionView.checkmarkBackgroundView.layer.borderColor = theme.tableViewCellSelectionBackgroundColor.withAlphaComponent(0.3).cgColor
}
.store(in: &cell.disposeBag)
cell.pollOptionView.checkmarkBackgroundView.layer.borderWidth = 1
cell.pollOptionView.checkmarkBackgroundView.isHidden = false
cell.pollOptionView.checkmarkImageView.isHidden = true
case .on:
ThemeService.shared.currentTheme
.receive(on: DispatchQueue.main)
.sink { [weak cell] theme in
guard let cell = cell else { return }
cell.pollOptionView.checkmarkBackgroundView.backgroundColor = theme.tertiarySystemBackgroundColor
}
.store(in: &cell.disposeBag)
cell.pollOptionView.checkmarkBackgroundView.layer.borderColor = UIColor.clear.cgColor
cell.pollOptionView.checkmarkBackgroundView.layer.borderWidth = 0
cell.pollOptionView.checkmarkBackgroundView.isHidden = false
cell.pollOptionView.checkmarkImageView.isHidden = false
}
}
static func configure(cell: PollOptionTableViewCell, voteState state: PollItem.Attribute.VoteState) {
switch state {
case .hidden:
cell.pollOptionView.optionPercentageLabel.isHidden = true
cell.pollOptionView.voteProgressStripView.isHidden = true
cell.pollOptionView.voteProgressStripView.setProgress(0.0, animated: false)
case .reveal(let voted, let percentage, let animated):
cell.pollOptionView.optionPercentageLabel.isHidden = false
cell.pollOptionView.optionPercentageLabel.text = String(Int(100 * percentage)) + "%"
cell.pollOptionView.voteProgressStripView.isHidden = false
cell.pollOptionView.voteProgressStripView.tintColor = voted ? Asset.Colors.brandBlue.color : Asset.Colors.Poll.disabled.color
cell.pollOptionView.voteProgressStripView.setProgress(CGFloat(percentage), animated: animated)
}
}
}

View File

@ -10,24 +10,10 @@ import Combine
import MastodonSDK
import MastodonMeta
enum ProfileFieldItem {
case field(field: FieldValue, attribute: FieldItemAttribute)
case addEntry(attribute: AddEntryItemAttribute)
}
protocol ProfileFieldListSeparatorLineConfigurable: AnyObject {
var isLast: Bool { get set }
}
extension ProfileFieldItem {
var listSeparatorLineConfigurable: ProfileFieldListSeparatorLineConfigurable? {
switch self {
case .field(_, let attribute):
return attribute
case .addEntry(let attribute):
return attribute
}
}
enum ProfileFieldItem: Hashable {
case field(field: FieldValue)
case editField(field: FieldValue)
case addEntry
}
extension ProfileFieldItem {
@ -36,17 +22,29 @@ extension ProfileFieldItem {
var name: CurrentValueSubject<String, Never>
var value: CurrentValueSubject<String, Never>
let emojiMeta: MastodonContent.Emojis
init(id: UUID = UUID(), name: String, value: String) {
init(
id: UUID = UUID(),
name: String,
value: String,
emojiMeta: MastodonContent.Emojis
) {
self.id = id
self.name = CurrentValueSubject(name)
self.value = CurrentValueSubject(value)
self.emojiMeta = emojiMeta
}
static func == (lhs: ProfileFieldItem.FieldValue, rhs: ProfileFieldItem.FieldValue) -> Bool {
static func == (
lhs: ProfileFieldItem.FieldValue,
rhs: ProfileFieldItem.FieldValue
) -> Bool {
return lhs.id == rhs.id
&& lhs.name.value == rhs.name.value
&& lhs.value.value == rhs.value.value
&& lhs.emojiMeta == rhs.emojiMeta
}
func hash(into hasher: inout Hasher) {
@ -54,50 +52,3 @@ extension ProfileFieldItem {
}
}
}
extension ProfileFieldItem {
class FieldItemAttribute: Equatable, ProfileFieldListSeparatorLineConfigurable {
let emojiMeta = CurrentValueSubject<MastodonContent.Emojis, Never>([:])
var isEditing = false
var isLast = false
static func == (lhs: ProfileFieldItem.FieldItemAttribute, rhs: ProfileFieldItem.FieldItemAttribute) -> Bool {
return lhs.isEditing == rhs.isEditing
&& lhs.isLast == rhs.isLast
}
}
class AddEntryItemAttribute: Equatable, ProfileFieldListSeparatorLineConfigurable {
var isLast = false
static func == (lhs: ProfileFieldItem.AddEntryItemAttribute, rhs: ProfileFieldItem.AddEntryItemAttribute) -> Bool {
return lhs.isLast == rhs.isLast
}
}
}
extension ProfileFieldItem: Equatable {
static func == (lhs: ProfileFieldItem, rhs: ProfileFieldItem) -> Bool {
switch (lhs, rhs) {
case (.field(let fieldLeft, let attributeLeft), .field(let fieldRight, let attributeRight)):
return fieldLeft.id == fieldRight.id
&& attributeLeft == attributeRight
case (.addEntry(let attributeLeft), .addEntry(let attributeRight)):
return attributeLeft == attributeRight
default:
return false
}
}
}
extension ProfileFieldItem: Hashable {
func hash(into hasher: inout Hasher) {
switch self {
case .field(let field, _):
hasher.combine(field.id)
case .addEntry:
hasher.combine(String(describing: ProfileFieldItem.addEntry.self))
}
}
}

View File

@ -9,125 +9,124 @@ import os
import UIKit
import Combine
import MastodonMeta
import MastodonLocalization
enum ProfileFieldSection: Equatable, Hashable {
case main
}
extension ProfileFieldSection {
static func collectionViewDiffableDataSource(
for collectionView: UICollectionView,
profileFieldCollectionViewCellDelegate: ProfileFieldCollectionViewCellDelegate,
profileFieldAddEntryCollectionViewCellDelegate: ProfileFieldAddEntryCollectionViewCellDelegate
struct Configuration {
weak var profileFieldCollectionViewCellDelegate: ProfileFieldCollectionViewCellDelegate?
weak var profileFieldEditCollectionViewCellDelegate: ProfileFieldEditCollectionViewCellDelegate?
}
static func diffableDataSource(
collectionView: UICollectionView,
context: AppContext,
configuration: Configuration
) -> UICollectionViewDiffableDataSource<ProfileFieldSection, ProfileFieldItem> {
let dataSource = UICollectionViewDiffableDataSource<ProfileFieldSection, ProfileFieldItem>(collectionView: collectionView) {
[
weak profileFieldCollectionViewCellDelegate,
weak profileFieldAddEntryCollectionViewCellDelegate
] collectionView, indexPath, item in
collectionView.register(ProfileFieldCollectionViewHeaderFooterView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: ProfileFieldCollectionViewHeaderFooterView.headerReuseIdentifer)
collectionView.register(ProfileFieldCollectionViewHeaderFooterView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: ProfileFieldCollectionViewHeaderFooterView.footerReuseIdentifer)
let fieldCellRegistration = UICollectionView.CellRegistration<ProfileFieldCollectionViewCell, ProfileFieldItem> { cell, indexPath, item in
guard case let .field(field) = item else { return }
// set key
do {
let mastodonContent = MastodonContent(content: field.name.value, emojis: field.emojiMeta)
let metaContent = try MastodonMetaContent.convert(document: mastodonContent)
cell.keyMetaLabel.configure(content: metaContent)
} catch {
let content = PlaintextMetaContent(string: field.name.value)
cell.keyMetaLabel.configure(content: content)
}
// set value
do {
let mastodonContent = MastodonContent(content: field.value.value, emojis: field.emojiMeta)
let metaContent = try MastodonMetaContent.convert(document: mastodonContent)
cell.valueMetaLabel.configure(content: metaContent)
} catch {
let content = PlaintextMetaContent(string: field.value.value)
cell.valueMetaLabel.configure(content: content)
}
// set background
var backgroundConfiguration = UIBackgroundConfiguration.listPlainCell()
backgroundConfiguration.backgroundColor = UIColor.secondarySystemBackground
cell.backgroundConfiguration = backgroundConfiguration
cell.delegate = configuration.profileFieldCollectionViewCellDelegate
}
let editFieldCellRegistration = UICollectionView.CellRegistration<ProfileFieldEditCollectionViewCell, ProfileFieldItem> { cell, indexPath, item in
guard case let .editField(field) = item else { return }
cell.keyTextField.text = field.name.value
cell.valueTextField.text = field.value.value
NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: cell.keyTextField)
.compactMap { $0.object as? UITextField }
.map { $0.text ?? "" }
.removeDuplicates()
.assign(to: \.value, on: field.name)
.store(in: &cell.disposeBag)
NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: cell.valueTextField)
.compactMap { $0.object as? UITextField }
.map { $0.text ?? "" }
.removeDuplicates()
.assign(to: \.value, on: field.value)
.store(in: &cell.disposeBag)
// set background
var backgroundConfiguration = UIBackgroundConfiguration.listPlainCell()
backgroundConfiguration.backgroundColor = UIColor.secondarySystemBackground
cell.backgroundConfiguration = backgroundConfiguration
cell.delegate = configuration.profileFieldEditCollectionViewCellDelegate
}
let addEntryCellRegistration = UICollectionView.CellRegistration<ProfileFieldAddEntryCollectionViewCell, ProfileFieldItem> { cell, indexPath, item in
guard case .addEntry = item else { return }
var backgroundConfiguration = UIBackgroundConfiguration.listPlainCell()
backgroundConfiguration.backgroundColorTransformer = .init { [weak cell] _ in
guard let cell = cell else {
return .secondarySystemBackground
}
let state = cell.configurationState
if state.isHighlighted || state.isSelected {
return .secondarySystemBackground.withAlphaComponent(0.5)
} else {
return .secondarySystemBackground
}
}
cell.backgroundConfiguration = backgroundConfiguration
}
let dataSource = UICollectionViewDiffableDataSource<ProfileFieldSection, ProfileFieldItem>(collectionView: collectionView) { collectionView, indexPath, item in
switch item {
case .field(let field, let attribute):
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ProfileFieldCollectionViewCell.self), for: indexPath) as! ProfileFieldCollectionViewCell
// set key
do {
let mastodonContent = MastodonContent(content: field.name.value, emojis: attribute.emojiMeta.value)
let metaContent = try MastodonMetaContent.convert(document: mastodonContent)
cell.fieldView.titleMetaLabel.configure(content: metaContent)
} catch {
let content = PlaintextMetaContent(string: field.name.value)
cell.fieldView.titleMetaLabel.configure(content: content)
}
cell.fieldView.titleTextField.text = field.name.value
Publishers.CombineLatest(
field.name.removeDuplicates(),
attribute.emojiMeta.removeDuplicates()
case .field:
return collectionView.dequeueConfiguredReusableCell(
using: fieldCellRegistration,
for: indexPath,
item: item
)
.receive(on: RunLoop.main)
.sink { [weak cell] name, emojiMeta in
guard let cell = cell else { return }
do {
let mastodonContent = MastodonContent(content: name, emojis: emojiMeta)
let metaContent = try MastodonMetaContent.convert(document: mastodonContent)
cell.fieldView.titleMetaLabel.configure(content: metaContent)
} catch {
let content = PlaintextMetaContent(string: name)
cell.fieldView.titleMetaLabel.configure(content: content)
}
// only bind label. The text field should only set once
}
.store(in: &cell.disposeBag)
// set value
do {
let mastodonContent = MastodonContent(content: field.value.value, emojis: attribute.emojiMeta.value)
let metaContent = try MastodonMetaContent.convert(document: mastodonContent)
cell.fieldView.valueMetaLabel.configure(content: metaContent)
} catch {
let content = PlaintextMetaContent(string: field.value.value)
cell.fieldView.valueMetaLabel.configure(content: content)
}
cell.fieldView.valueTextField.text = field.value.value
Publishers.CombineLatest(
field.value.removeDuplicates(),
attribute.emojiMeta.removeDuplicates()
case .editField:
return collectionView.dequeueConfiguredReusableCell(
using: editFieldCellRegistration,
for: indexPath,
item: item
)
case .addEntry:
return collectionView.dequeueConfiguredReusableCell(
using: addEntryCellRegistration,
for: indexPath,
item: item
)
.receive(on: RunLoop.main)
.sink { [weak cell] value, emojiMeta in
guard let cell = cell else { return }
do {
let mastodonContent = MastodonContent(content: value, emojis: emojiMeta)
let metaContent = try MastodonMetaContent.convert(document: mastodonContent)
cell.fieldView.valueMetaLabel.configure(content: metaContent)
} catch {
let content = PlaintextMetaContent(string: value)
cell.fieldView.valueMetaLabel.configure(content: content)
}
// only bind label. The text field should only set once
}
.store(in: &cell.disposeBag)
// bind editing
if attribute.isEditing {
cell.fieldView.name
.removeDuplicates()
.receive(on: RunLoop.main)
.assign(to: \.value, on: field.name)
.store(in: &cell.disposeBag)
cell.fieldView.value
.removeDuplicates()
.receive(on: RunLoop.main)
.assign(to: \.value, on: field.value)
.store(in: &cell.disposeBag)
}
// setup editing state
cell.fieldView.titleTextField.isHidden = !attribute.isEditing
cell.fieldView.valueTextField.isHidden = !attribute.isEditing
cell.fieldView.titleMetaLabel.isHidden = attribute.isEditing
cell.fieldView.valueMetaLabel.isHidden = attribute.isEditing
// set control hidden
let isHidden = !attribute.isEditing
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update editing state: %s", ((#file as NSString).lastPathComponent), #line, #function, isHidden ? "true" : "false")
cell.editButton.isHidden = isHidden
cell.reorderBarImageView.isHidden = isHidden
// update separator line
cell.bottomSeparatorLine.isHidden = attribute.isLast
cell.delegate = profileFieldCollectionViewCellDelegate
return cell
case .addEntry(let attribute):
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ProfileFieldAddEntryCollectionViewCell.self), for: indexPath) as! ProfileFieldAddEntryCollectionViewCell
cell.bottomSeparatorLine.isHidden = attribute.isLast
cell.delegate = profileFieldAddEntryCollectionViewCellDelegate
return cell
}
}
@ -135,6 +134,7 @@ extension ProfileFieldSection {
switch kind {
case UICollectionView.elementKindSectionHeader:
let reusableView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: ProfileFieldCollectionViewHeaderFooterView.headerReuseIdentifer, for: indexPath) as! ProfileFieldCollectionViewHeaderFooterView
reusableView.frame.size.height = 20
return reusableView
case UICollectionView.elementKindSectionFooter:
let reusableView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: ProfileFieldCollectionViewHeaderFooterView.footerReuseIdentifer, for: indexPath) as! ProfileFieldCollectionViewHeaderFooterView

View File

@ -0,0 +1,13 @@
//
// RecommendAccountItem.swift
// Mastodon
//
// Created by MainasuK on 2022-2-10.
//
import Foundation
import CoreDataStack
enum RecommendAccountItem: Hashable {
case account(ManagedObjectRecord<MastodonUser>)
}

View File

@ -0,0 +1,157 @@
//
// RecommendAccountSection.swift
// Mastodon
//
// Created by sxiaojian on 2021/4/1.
//
import CoreData
import CoreDataStack
import Foundation
import MastodonSDK
import UIKit
import MetaTextKit
import MastodonMeta
import Combine
enum RecommendAccountSection: Equatable, Hashable {
case main
}
//extension RecommendAccountSection {
// static func collectionViewDiffableDataSource(
// for collectionView: UICollectionView,
// dependency: NeedsDependency,
// delegate: SearchRecommendAccountsCollectionViewCellDelegate,
// managedObjectContext: NSManagedObjectContext
// ) -> UICollectionViewDiffableDataSource<RecommendAccountSection, NSManagedObjectID> {
// UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak delegate] collectionView, indexPath, objectID -> UICollectionViewCell? in
// let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: SearchRecommendAccountsCollectionViewCell.self), for: indexPath) as! SearchRecommendAccountsCollectionViewCell
// managedObjectContext.performAndWait {
// let user = managedObjectContext.object(with: objectID) as! MastodonUser
// configure(cell: cell, user: user, dependency: dependency)
// }
// cell.delegate = delegate
// return cell
// }
// }
//
// static func configure(
// cell: SearchRecommendAccountsCollectionViewCell,
// user: MastodonUser,
// dependency: NeedsDependency
// ) {
// configureContent(cell: cell, user: user)
//
// if let currentMastodonUser = dependency.context.authenticationService.activeMastodonAuthentication.value?.user {
// configureFollowButton(with: user, currentMastodonUser: currentMastodonUser, followButton: cell.followButton)
// }
//
// Publishers.CombineLatest(
// ManagedObjectObserver.observe(object: user).eraseToAnyPublisher().mapError { $0 as Error },
// dependency.context.authenticationService.activeMastodonAuthentication.setFailureType(to: Error.self)
// )
// .receive(on: DispatchQueue.main)
// .sink { _ in
// // do nothing
// } receiveValue: { [weak cell] change, authentication in
// guard let cell = cell else { return }
// guard case .update(let object) = change.changeType,
// let user = object as? MastodonUser else { return }
// guard let currentMastodonUser = authentication?.user else { return }
//
// configureFollowButton(with: user, currentMastodonUser: currentMastodonUser, followButton: cell.followButton)
// }
// .store(in: &cell.disposeBag)
//
// }
//
// static func configureContent(
// cell: SearchRecommendAccountsCollectionViewCell,
// user: MastodonUser
// ) {
// do {
// let mastodonContent = MastodonContent(content: user.displayNameWithFallback, emojis: user.emojis.asDictionary)
// let metaContent = try MastodonMetaContent.convert(document: mastodonContent)
// cell.displayNameLabel.configure(content: metaContent)
// } catch {
// let metaContent = PlaintextMetaContent(string: user.displayNameWithFallback)
// cell.displayNameLabel.configure(content: metaContent)
// }
// cell.acctLabel.text = "@" + user.acct
// cell.avatarImageView.af.setImage(
// withURL: user.avatarImageURLWithFallback(domain: user.domain),
// placeholderImage: UIImage.placeholder(color: .systemFill),
// imageTransition: .crossDissolve(0.2)
// )
// cell.headerImageView.af.setImage(
// withURL: URL(string: user.header)!,
// placeholderImage: UIImage.placeholder(color: .systemFill),
// imageTransition: .crossDissolve(0.2)
// )
// }
//
// static func configureFollowButton(
// with mastodonUser: MastodonUser,
// currentMastodonUser: MastodonUser,
// followButton: HighlightDimmableButton
// ) {
// let relationshipActionSet = relationShipActionSet(mastodonUser: mastodonUser, currentMastodonUser: currentMastodonUser)
// followButton.setTitle(relationshipActionSet.title, for: .normal)
// }
//
// static func relationShipActionSet(
// mastodonUser: MastodonUser,
// currentMastodonUser: MastodonUser
// ) -> ProfileViewModel.RelationshipActionOptionSet {
// var relationshipActionSet = ProfileViewModel.RelationshipActionOptionSet([.follow])
// let isFollowing = mastodonUser.followingBy.flatMap { $0.contains(currentMastodonUser) } ?? false
// if isFollowing {
// relationshipActionSet.insert(.following)
// }
//
// let isPending = mastodonUser.followRequestedBy.flatMap { $0.contains(currentMastodonUser) } ?? false
// if isPending {
// relationshipActionSet.insert(.pending)
// }
//
// let isBlocking = mastodonUser.blockingBy.flatMap { $0.contains(currentMastodonUser) } ?? false
// if isBlocking {
// relationshipActionSet.insert(.blocking)
// }
//
// let isBlockedBy = currentMastodonUser.blockingBy.flatMap { $0.contains(mastodonUser) } ?? false
// if isBlockedBy {
// relationshipActionSet.insert(.blocked)
// }
// return relationshipActionSet
// }
//
//}
//
extension RecommendAccountSection {
struct Configuration {
weak var suggestionAccountTableViewCellDelegate: SuggestionAccountTableViewCellDelegate?
}
static func tableViewDiffableDataSource(
tableView: UITableView,
context: AppContext,
configuration: Configuration
) -> UITableViewDiffableDataSource<RecommendAccountSection, RecommendAccountItem> {
UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SuggestionAccountTableViewCell.self)) as! SuggestionAccountTableViewCell
switch item {
case .account(let record):
context.managedObjectContext.performAndWait {
guard let user = record.object(in: context.managedObjectContext) else { return }
cell.config(with: user)
}
}
cell.delegate = configuration.suggestionAccountTableViewCellDelegate
return cell
}
}
}

View File

@ -0,0 +1,40 @@
//
// ReportItem.swift
// Mastodon
//
// Created by MainasuK on 2022-1-27.
//
import Foundation
import CoreDataStack
enum ReportItem: Hashable {
case header(context: HeaderContext)
case status(record: ManagedObjectRecord<Status>)
case comment(context: CommentContext)
case result(record: ManagedObjectRecord<MastodonUser>)
case bottomLoader
}
extension ReportItem {
struct HeaderContext: Hashable {
let primaryLabelText: String
let secondaryLabelText: String
}
class CommentContext: Hashable {
let id = UUID()
@Published var comment: String = ""
static func == (
lhs: ReportItem.CommentContext,
rhs: ReportItem.CommentContext
) -> Bool {
lhs.comment == rhs.comment
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
}

View File

@ -0,0 +1,117 @@
//
// ReportSection.swift
// Mastodon
//
// Created by ihugo on 2021/4/20.
//
import Combine
import CoreData
import CoreDataStack
import Foundation
import MastodonSDK
import UIKit
import os.log
import MastodonAsset
import MastodonLocalization
enum ReportSection: Equatable, Hashable {
case main
}
extension ReportSection {
struct Configuration {
}
static func diffableDataSource(
tableView: UITableView,
context: AppContext,
configuration: Configuration
) -> UITableViewDiffableDataSource<ReportSection, ReportItem> {
tableView.register(ReportHeadlineTableViewCell.self, forCellReuseIdentifier: String(describing: ReportHeadlineTableViewCell.self))
tableView.register(ReportStatusTableViewCell.self, forCellReuseIdentifier: String(describing: ReportStatusTableViewCell.self))
tableView.register(ReportCommentTableViewCell.self, forCellReuseIdentifier: String(describing: ReportCommentTableViewCell.self))
tableView.register(ReportResultActionTableViewCell.self, forCellReuseIdentifier: String(describing: ReportResultActionTableViewCell.self))
tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
return UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in
switch item {
case .header(let headerContext):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ReportHeadlineTableViewCell.self), for: indexPath) as! ReportHeadlineTableViewCell
cell.primaryLabel.text = headerContext.primaryLabelText
cell.secondaryLabel.text = headerContext.secondaryLabelText
return cell
case .status(let record):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ReportStatusTableViewCell.self), for: indexPath) as! ReportStatusTableViewCell
context.managedObjectContext.performAndWait {
guard let status = record.object(in: context.managedObjectContext) else { return }
configure(
context: context,
tableView: tableView,
cell: cell,
viewModel: .init(value: status),
configuration: configuration
)
}
return cell
case .comment(let commentContext):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ReportCommentTableViewCell.self), for: indexPath) as! ReportCommentTableViewCell
cell.commentTextView.text = commentContext.comment
NotificationCenter.default.publisher(for: UITextView.textDidChangeNotification, object: cell.commentTextView)
.receive(on: DispatchQueue.main)
.sink { [weak cell] notification in
guard let cell = cell else { return }
commentContext.comment = cell.commentTextView.text
// fix shadow get animation issue when cell height changes
UIView.performWithoutAnimation {
tableView.beginUpdates()
tableView.endUpdates()
}
}
.store(in: &cell.disposeBag)
return cell
case .result(let record):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ReportResultActionTableViewCell.self), for: indexPath) as! ReportResultActionTableViewCell
context.managedObjectContext.performAndWait {
guard let user = record.object(in: context.managedObjectContext) else { return }
cell.avatarImageView.configure(configuration: .init(url: user.avatarImageURL()))
}
return cell
case .bottomLoader:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell
cell.activityIndicatorView.startAnimating()
return cell
}
}
}
}
extension ReportSection {
static func configure(
context: AppContext,
tableView: UITableView,
cell: ReportStatusTableViewCell,
viewModel: ReportStatusTableViewCell.ViewModel,
configuration: Configuration
) {
StatusSection.setupStatusPollDataSource(
context: context,
statusView: cell.statusView
)
context.authenticationService.activeMastodonAuthenticationBox
.map { $0 as UserIdentifier? }
.assign(to: \.userIdentifier, on: cell.statusView.viewModel)
.store(in: &cell.disposeBag)
cell.configure(
tableView: tableView,
viewModel: viewModel
)
}
}

View File

@ -1,150 +0,0 @@
//
// RecommendAccountSection.swift
// Mastodon
//
// Created by sxiaojian on 2021/4/1.
//
import CoreData
import CoreDataStack
import Foundation
import MastodonSDK
import UIKit
import MetaTextKit
import MastodonMeta
import Combine
enum RecommendAccountSection: Equatable, Hashable {
case main
}
extension RecommendAccountSection {
static func collectionViewDiffableDataSource(
for collectionView: UICollectionView,
dependency: NeedsDependency,
delegate: SearchRecommendAccountsCollectionViewCellDelegate,
managedObjectContext: NSManagedObjectContext
) -> UICollectionViewDiffableDataSource<RecommendAccountSection, NSManagedObjectID> {
UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak delegate] collectionView, indexPath, objectID -> UICollectionViewCell? in
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: SearchRecommendAccountsCollectionViewCell.self), for: indexPath) as! SearchRecommendAccountsCollectionViewCell
managedObjectContext.performAndWait {
let user = managedObjectContext.object(with: objectID) as! MastodonUser
configure(cell: cell, user: user, dependency: dependency)
}
cell.delegate = delegate
return cell
}
}
static func configure(
cell: SearchRecommendAccountsCollectionViewCell,
user: MastodonUser,
dependency: NeedsDependency
) {
configureContent(cell: cell, user: user)
if let currentMastodonUser = dependency.context.authenticationService.activeMastodonAuthentication.value?.user {
configureFollowButton(with: user, currentMastodonUser: currentMastodonUser, followButton: cell.followButton)
}
Publishers.CombineLatest(
ManagedObjectObserver.observe(object: user).eraseToAnyPublisher().mapError { $0 as Error },
dependency.context.authenticationService.activeMastodonAuthentication.setFailureType(to: Error.self)
)
.receive(on: DispatchQueue.main)
.sink { _ in
// do nothing
} receiveValue: { [weak cell] change, authentication in
guard let cell = cell else { return }
guard case .update(let object) = change.changeType,
let user = object as? MastodonUser else { return }
guard let currentMastodonUser = authentication?.user else { return }
configureFollowButton(with: user, currentMastodonUser: currentMastodonUser, followButton: cell.followButton)
}
.store(in: &cell.disposeBag)
}
static func configureContent(
cell: SearchRecommendAccountsCollectionViewCell,
user: MastodonUser
) {
do {
let mastodonContent = MastodonContent(content: user.displayNameWithFallback, emojis: user.emojiMeta)
let metaContent = try MastodonMetaContent.convert(document: mastodonContent)
cell.displayNameLabel.configure(content: metaContent)
} catch {
let metaContent = PlaintextMetaContent(string: user.displayNameWithFallback)
cell.displayNameLabel.configure(content: metaContent)
}
cell.acctLabel.text = "@" + user.acct
cell.avatarImageView.af.setImage(
withURL: user.avatarImageURLWithFallback(domain: user.domain),
placeholderImage: UIImage.placeholder(color: .systemFill),
imageTransition: .crossDissolve(0.2)
)
cell.headerImageView.af.setImage(
withURL: URL(string: user.header)!,
placeholderImage: UIImage.placeholder(color: .systemFill),
imageTransition: .crossDissolve(0.2)
)
}
static func configureFollowButton(
with mastodonUser: MastodonUser,
currentMastodonUser: MastodonUser,
followButton: HighlightDimmableButton
) {
let relationshipActionSet = relationShipActionSet(mastodonUser: mastodonUser, currentMastodonUser: currentMastodonUser)
followButton.setTitle(relationshipActionSet.title, for: .normal)
}
static func relationShipActionSet(
mastodonUser: MastodonUser,
currentMastodonUser: MastodonUser
) -> ProfileViewModel.RelationshipActionOptionSet {
var relationshipActionSet = ProfileViewModel.RelationshipActionOptionSet([.follow])
let isFollowing = mastodonUser.followingBy.flatMap { $0.contains(currentMastodonUser) } ?? false
if isFollowing {
relationshipActionSet.insert(.following)
}
let isPending = mastodonUser.followRequestedBy.flatMap { $0.contains(currentMastodonUser) } ?? false
if isPending {
relationshipActionSet.insert(.pending)
}
let isBlocking = mastodonUser.blockingBy.flatMap { $0.contains(currentMastodonUser) } ?? false
if isBlocking {
relationshipActionSet.insert(.blocking)
}
let isBlockedBy = currentMastodonUser.blockingBy.flatMap { $0.contains(mastodonUser) } ?? false
if isBlockedBy {
relationshipActionSet.insert(.blocked)
}
return relationshipActionSet
}
}
extension RecommendAccountSection {
static func tableViewDiffableDataSource(
for tableView: UITableView,
managedObjectContext: NSManagedObjectContext,
viewModel: SuggestionAccountViewModel,
delegate: SuggestionAccountTableViewCellDelegate
) -> UITableViewDiffableDataSource<RecommendAccountSection, NSManagedObjectID> {
UITableViewDiffableDataSource(tableView: tableView) { [weak viewModel, weak delegate] (tableView, indexPath, objectID) -> UITableViewCell? in
guard let viewModel = viewModel else { return nil }
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SuggestionAccountTableViewCell.self)) as! SuggestionAccountTableViewCell
let user = managedObjectContext.object(with: objectID) as! MastodonUser
let isSelected = viewModel.selectedAccounts.value.contains(objectID)
cell.delegate = delegate
cell.config(with: user, isSelected: isSelected)
return cell
}
}
}

View File

@ -1,26 +0,0 @@
//
// RecommendHashTagSection.swift
// Mastodon
//
// Created by sxiaojian on 2021/4/1.
//
import Foundation
import MastodonSDK
import UIKit
enum RecommendHashTagSection: Equatable, Hashable {
case main
}
extension RecommendHashTagSection {
static func collectionViewDiffableDataSource(
for collectionView: UICollectionView
) -> UICollectionViewDiffableDataSource<RecommendHashTagSection, Mastodon.Entity.Tag> {
UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, tag -> UICollectionViewCell? in
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: SearchRecommendTagsCollectionViewCell.self), for: indexPath) as! SearchRecommendTagsCollectionViewCell
cell.config(with: tag)
return cell
}
}
}

View File

@ -7,35 +7,9 @@
import Foundation
import CoreData
import CoreDataStack
enum SearchHistoryItem {
case account(objectID: NSManagedObjectID)
case hashtag(objectID: NSManagedObjectID)
case status(objectID: NSManagedObjectID, attribute: Item.StatusAttribute)
}
extension SearchHistoryItem: Hashable {
static func == (lhs: SearchHistoryItem, rhs: SearchHistoryItem) -> Bool {
switch (lhs, rhs) {
case (.account(let objectIDLeft), account(let objectIDRight)):
return objectIDLeft == objectIDRight
case (.hashtag(let objectIDLeft), hashtag(let objectIDRight)):
return objectIDLeft == objectIDRight
case (.status(let objectIDLeft, _), status(let objectIDRight, _)):
return objectIDLeft == objectIDRight
default:
return false
}
}
func hash(into hasher: inout Hasher) {
switch self {
case .account(let objectID):
hasher.combine(objectID)
case .hashtag(let objectID):
hasher.combine(objectID)
case .status(let objectID, _):
hasher.combine(objectID)
}
}
enum SearchHistoryItem: Hashable {
case hashtag(ManagedObjectRecord<Tag>)
case user(ManagedObjectRecord<MastodonUser>)
}

View File

@ -13,28 +13,80 @@ enum SearchHistorySection: Hashable {
}
extension SearchHistorySection {
static func tableViewDiffableDataSource(
for tableView: UITableView,
dependency: NeedsDependency
) -> UITableViewDiffableDataSource<SearchHistorySection, SearchHistoryItem> {
UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in
struct Configuration {
weak var searchHistorySectionHeaderCollectionReusableViewDelegate: SearchHistorySectionHeaderCollectionReusableViewDelegate?
}
static func diffableDataSource(
collectionView: UICollectionView,
context: AppContext,
configuration: Configuration
) -> UICollectionViewDiffableDataSource<SearchHistorySection, SearchHistoryItem> {
let userCellRegister = UICollectionView.CellRegistration<SearchHistoryUserCollectionViewCell, ManagedObjectRecord<MastodonUser>> { cell, indexPath, item in
context.managedObjectContext.performAndWait {
guard let user = item.object(in: context.managedObjectContext) else { return }
cell.configure(viewModel: .init(value: user))
}
}
let hashtagCellRegister = UICollectionView.CellRegistration<UICollectionViewListCell, ManagedObjectRecord<Tag>> { cell, indexPath, item in
context.managedObjectContext.performAndWait {
guard let hashtag = item.object(in: context.managedObjectContext) else { return }
var contentConfiguration = cell.defaultContentConfiguration()
contentConfiguration.text = "#" + hashtag.name
cell.contentConfiguration = contentConfiguration
}
var backgroundConfiguration = UIBackgroundConfiguration.listGroupedCell()
backgroundConfiguration.backgroundColorTransformer = .init { [weak cell] _ in
guard let state = cell?.configurationState else {
return ThemeService.shared.currentTheme.value.secondarySystemGroupedBackgroundColor
}
if state.isHighlighted || state.isSelected {
return ThemeService.shared.currentTheme.value.tableViewCellSelectionBackgroundColor
}
return ThemeService.shared.currentTheme.value.secondarySystemGroupedBackgroundColor
}
cell.backgroundConfiguration = backgroundConfiguration
}
let dataSource = UICollectionViewDiffableDataSource<SearchHistorySection, SearchHistoryItem>(collectionView: collectionView) { collectionView, indexPath, item in
switch item {
case .account(let objectID):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchResultTableViewCell.self), for: indexPath) as! SearchResultTableViewCell
if let user = try? dependency.context.managedObjectContext.existingObject(with: objectID) as? MastodonUser {
cell.config(with: user)
}
return cell
case .hashtag(let objectID):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchResultTableViewCell.self), for: indexPath) as! SearchResultTableViewCell
if let hashtag = try? dependency.context.managedObjectContext.existingObject(with: objectID) as? Tag {
cell.config(with: hashtag)
}
return cell
case .status:
// Should not show status in the history list
return UITableViewCell()
} // end switch
} // end UITableViewDiffableDataSource
case .user(let record):
return collectionView.dequeueConfiguredReusableCell(
using: userCellRegister,
for: indexPath, item: record)
case .hashtag(let record):
return collectionView.dequeueConfiguredReusableCell(
using: hashtagCellRegister,
for: indexPath, item: record)
}
}
let trendHeaderRegister = UICollectionView.SupplementaryRegistration<SearchHistorySectionHeaderCollectionReusableView>(elementKind: UICollectionView.elementKindSectionHeader) { [weak dataSource] supplementaryView, elementKind, indexPath in
supplementaryView.delegate = configuration.searchHistorySectionHeaderCollectionReusableViewDelegate
guard let dataSource = dataSource else { return }
let sections = dataSource.snapshot().sectionIdentifiers
guard indexPath.section < sections.count else { return }
let section = sections[indexPath.section]
}
dataSource.supplementaryViewProvider = { (collectionView: UICollectionView, elementKind: String, indexPath: IndexPath) in
let fallback = UICollectionReusableView()
switch elementKind {
case UICollectionView.elementKindSectionHeader:
return collectionView.dequeueConfiguredReusableSupplementary(using: trendHeaderRegister, for: indexPath)
default:
assertionFailure()
return fallback
}
}
return dataSource
} // end func
}

View File

@ -0,0 +1,13 @@
//
// SearchItem.swift
// Mastodon
//
// Created by MainasuK on 2022-1-18.
//
import Foundation
import MastodonSDK
enum SearchItem: Hashable {
case trend(Mastodon.Entity.Tag)
}

View File

@ -5,14 +5,15 @@
// Created by sxiaojian on 2021/4/6.
//
import CoreData
import Foundation
import CoreData
import CoreDataStack
import MastodonSDK
enum SearchResultItem {
enum SearchResultItem: Hashable {
case user(ManagedObjectRecord<MastodonUser>)
case status(ManagedObjectRecord<Status>)
case hashtag(tag: Mastodon.Entity.Tag)
case account(account: Mastodon.Entity.Account)
case status(statusObjectID: NSManagedObjectID, attribute: Item.StatusAttribute)
case bottomLoader(attribute: BottomLoaderAttribute)
}
@ -26,7 +27,10 @@ extension SearchResultItem {
self.isNoResult = isEmptyResult
}
static func == (lhs: SearchResultItem.BottomLoaderAttribute, rhs: SearchResultItem.BottomLoaderAttribute) -> Bool {
static func == (
lhs: SearchResultItem.BottomLoaderAttribute,
rhs: SearchResultItem.BottomLoaderAttribute
) -> Bool {
return lhs.id == rhs.id
}
@ -35,60 +39,3 @@ extension SearchResultItem {
}
}
}
extension SearchResultItem: Equatable {
static func == (lhs: SearchResultItem, rhs: SearchResultItem) -> Bool {
switch (lhs, rhs) {
case (.hashtag(let tagLeft), .hashtag(let tagRight)):
return tagLeft == tagRight
case (.account(let accountLeft), .account(let accountRight)):
return accountLeft == accountRight
case (.status(let idLeft, _), .status(let idRight, _)):
return idLeft == idRight
case (.bottomLoader(let attributeLeft), .bottomLoader(let attributeRight)):
return attributeLeft == attributeRight
default:
return false
}
}
}
extension SearchResultItem: Hashable {
func hash(into hasher: inout Hasher) {
switch self {
case .account(let account):
hasher.combine(String(describing: SearchResultItem.account.self))
hasher.combine(account.id)
case .hashtag(let tag):
hasher.combine(String(describing: SearchResultItem.hashtag.self))
hasher.combine(tag.name)
case .status(let id, _):
hasher.combine(id)
case .bottomLoader(let attribute):
hasher.combine(attribute)
}
}
}
extension SearchResultItem {
var sortKey: String? {
switch self {
case .account(let account): return account.displayName.lowercased()
case .hashtag(let hashtag): return hashtag.name.lowercased()
default: return nil
}
}
}
extension SearchResultItem {
var statusObjectItem: StatusObjectItem? {
switch self {
case .status(let objectID, _):
return .status(objectID: objectID)
case .hashtag,
.account,
.bottomLoader:
return nil
}
}
}

View File

@ -5,51 +5,70 @@
// Created by sxiaojian on 2021/4/6.
//
import os.log
import Foundation
import MastodonSDK
import UIKit
import CoreData
import CoreDataStack
import MastodonAsset
import MastodonLocalization
import MastodonUI
enum SearchResultSection: Equatable, Hashable {
enum SearchResultSection: Hashable {
case main
}
extension SearchResultSection {
static let logger = Logger(subsystem: "SearchResultSection", category: "logic")
struct Configuration {
weak var statusViewTableViewCellDelegate: StatusTableViewCellDelegate?
weak var userTableViewCellDelegate: UserTableViewCellDelegate?
}
static func tableViewDiffableDataSource(
for tableView: UITableView,
dependency: NeedsDependency,
statusTableViewCellDelegate: StatusTableViewCellDelegate
tableView: UITableView,
context: AppContext,
configuration: Configuration
) -> UITableViewDiffableDataSource<SearchResultSection, SearchResultItem> {
UITableViewDiffableDataSource(tableView: tableView) { [
weak statusTableViewCellDelegate
] tableView, indexPath, item -> UITableViewCell? in
tableView.register(UserTableViewCell.self, forCellReuseIdentifier: String(describing: UserTableViewCell.self))
tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self))
tableView.register(HashtagTableViewCell.self, forCellReuseIdentifier: String(describing: HashtagTableViewCell.self))
tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
return UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in
switch item {
case .account(let account):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchResultTableViewCell.self), for: indexPath) as! SearchResultTableViewCell
cell.config(with: account)
return cell
case .hashtag(let tag):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchResultTableViewCell.self), for: indexPath) as! SearchResultTableViewCell
cell.config(with: tag)
return cell
case .status(let statusObjectID, let attribute):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell
if let status = try? dependency.context.managedObjectContext.existingObject(with: statusObjectID) as? Status {
let activeMastodonAuthenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value
let requestUserID = activeMastodonAuthenticationBox?.userID ?? ""
StatusSection.configure(
cell: cell,
case .user(let record):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: UserTableViewCell.self), for: indexPath) as! UserTableViewCell
context.managedObjectContext.performAndWait {
guard let user = record.object(in: context.managedObjectContext) else { return }
configure(
context: context,
tableView: tableView,
timelineContext: .search,
dependency: dependency,
readableLayoutFrame: tableView.readableContentGuide.layoutFrame,
status: status,
requestUserID: requestUserID,
statusItemAttribute: attribute
cell: cell,
viewModel: .init(value: .user(user)),
configuration: configuration
)
}
cell.delegate = statusTableViewCellDelegate
return cell
case .status(let record):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell
context.managedObjectContext.performAndWait {
guard let status = record.object(in: context.managedObjectContext) else { return }
configure(
context: context,
tableView: tableView,
cell: cell,
viewModel: StatusTableViewCell.ViewModel(value: .status(status)),
configuration: configuration
)
}
return cell
case .hashtag(let tag):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: HashtagTableViewCell.self)) as! HashtagTableViewCell
cell.primaryLabel.configure(content: PlaintextMetaContent(string: "#" + tag.name))
return cell
case .bottomLoader(let attribute):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) as! TimelineBottomLoaderTableViewCell
@ -63,7 +82,49 @@ extension SearchResultSection {
cell.loadMoreLabel.isHidden = true
}
return cell
} // end switch
}
} // end UITableViewDiffableDataSource
} // end func
}
extension SearchResultSection {
static func configure(
context: AppContext,
tableView: UITableView,
cell: StatusTableViewCell,
viewModel: StatusTableViewCell.ViewModel,
configuration: Configuration
) {
StatusSection.setupStatusPollDataSource(
context: context,
statusView: cell.statusView
)
context.authenticationService.activeMastodonAuthenticationBox
.map { $0 as UserIdentifier? }
.assign(to: \.userIdentifier, on: cell.statusView.viewModel)
.store(in: &cell.disposeBag)
cell.configure(
tableView: tableView,
viewModel: viewModel,
delegate: configuration.statusViewTableViewCellDelegate
)
}
static func configure(
context: AppContext,
tableView: UITableView,
cell: UserTableViewCell,
viewModel: UserTableViewCell.ViewModel,
configuration: Configuration
) {
cell.configure(
tableView: tableView,
viewModel: viewModel,
delegate: configuration.userTableViewCellDelegate
)
}
}

View File

@ -0,0 +1,76 @@
//
// SearchSection.swift
// Mastodon
//
// Created by MainasuK on 2022-1-18.
//
import UIKit
import MastodonSDK
import MastodonLocalization
enum SearchSection: Hashable {
case trend
}
extension SearchSection {
static func diffableDataSource(
collectionView: UICollectionView,
context: AppContext
) -> UICollectionViewDiffableDataSource<SearchSection, SearchItem> {
let trendCellRegister = UICollectionView.CellRegistration<TrendCollectionViewCell, Mastodon.Entity.Tag> { cell, indexPath, item in
cell.primaryLabel.text = "#" + item.name
cell.secondaryLabel.text = L10n.Scene.Search.Recommend.HashTag.peopleTalking(item.talkingPeopleCount ?? 0)
cell.lineChartView.data = (item.history ?? [])
.sorted(by: { $0.day < $1.day }) // latest last
.map { entry in
guard let point = Int(entry.accounts) else {
return .zero
}
return CGFloat(point)
}
}
let dataSource = UICollectionViewDiffableDataSource<SearchSection, SearchItem>(
collectionView: collectionView
) { collectionView, indexPath, item in
switch item {
case .trend(let hashtag):
let cell = collectionView.dequeueConfiguredReusableCell(
using: trendCellRegister,
for: indexPath,
item: hashtag
)
return cell
}
}
let trendHeaderRegister = UICollectionView.SupplementaryRegistration<TrendSectionHeaderCollectionReusableView>(elementKind: UICollectionView.elementKindSectionHeader) { supplementaryView, elementKind, indexPath in
// do nothing
}
dataSource.supplementaryViewProvider = { [weak dataSource] (collectionView: UICollectionView, elementKind: String, indexPath: IndexPath) in
let fallback = UICollectionReusableView()
guard let dataSource = dataSource else { return fallback }
let sections = dataSource.snapshot().sectionIdentifiers
guard indexPath.section < sections.count else { return fallback }
let section = sections[indexPath.section]
switch elementKind {
case UICollectionView.elementKindSectionHeader:
switch section {
case .trend:
return collectionView.dequeueConfiguredReusableSupplementary(using: trendHeaderRegister, for: indexPath)
}
default:
assertionFailure()
return fallback
}
}
return dataSource
} // end func
}

View File

@ -7,11 +7,14 @@
import UIKit
import CoreData
import CoreDataStack
import MastodonAsset
import MastodonLocalization
enum SettingsItem {
case appearance(settingObjectID: NSManagedObjectID)
case notification(settingObjectID: NSManagedObjectID, switchMode: NotificationSwitchMode)
case preference(settingObjectID: NSManagedObjectID, preferenceType: PreferenceType)
case appearance(record: ManagedObjectRecord<Setting>)
case preference(settingRecord: ManagedObjectRecord<Setting>, preferenceType: PreferenceType)
case notification(settingRecord: ManagedObjectRecord<Setting>, switchMode: NotificationSwitchMode)
case boringZone(item: Link)
case spicyZone(item: Link)
}
@ -19,9 +22,10 @@ enum SettingsItem {
extension SettingsItem {
enum AppearanceMode: String {
case automatic
case system
case reallyDark
case sortaDark
case light
case dark
}
enum NotificationSwitchMode: CaseIterable, Hashable {
@ -41,14 +45,12 @@ extension SettingsItem {
}
enum PreferenceType: CaseIterable {
case darkMode
case disableAvatarAnimation
case disableEmojiAnimation
case useDefaultBrowser
var title: String {
switch self {
case .darkMode: return L10n.Scene.Settings.Section.Preference.trueBlackDarkMode
case .disableAvatarAnimation: return L10n.Scene.Settings.Section.Preference.disableAvatarAnimation
case .disableEmojiAnimation: return L10n.Scene.Settings.Section.Preference.disableEmojiAnimation
case .useDefaultBrowser: return L10n.Scene.Settings.Section.Preference.usingDefaultBrowser
@ -75,12 +77,12 @@ extension SettingsItem {
}
}
var textColor: UIColor {
var textColor: UIColor? {
switch self {
case .accountSettings: return Asset.Colors.brandBlue.color
case .github: return Asset.Colors.brandBlue.color
case .termsOfService: return Asset.Colors.brandBlue.color
case .privacyPolicy: return Asset.Colors.brandBlue.color
case .accountSettings: return nil // tintColor
case .github: return nil
case .termsOfService: return nil
case .privacyPolicy: return nil
case .clearMediaCache: return .systemRed
case .signOut: return .systemRed
}

View File

@ -8,19 +8,21 @@
import UIKit
import CoreData
import CoreDataStack
import MastodonAsset
import MastodonLocalization
enum SettingsSection: Hashable {
case appearance
case notifications
case preference
case notifications
case boringZone
case spicyZone
var title: String {
switch self {
case .appearance: return L10n.Scene.Settings.Section.Appearance.title
case .appearance: return "Look and Feel" // TODO: i18n
case .preference: return ""
case .notifications: return L10n.Scene.Settings.Section.Notifications.title
case .preference: return L10n.Scene.Settings.Section.Preference.title
case .boringZone: return L10n.Scene.Settings.Section.BoringZone.title
case .spicyZone: return L10n.Scene.Settings.Section.SpicyZone.title
}
@ -39,25 +41,38 @@ extension SettingsSection {
weak settingsToggleCellDelegate
] tableView, indexPath, item -> UITableViewCell? in
switch item {
case .appearance(let objectID):
case .appearance(let record):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsAppearanceTableViewCell.self), for: indexPath) as! SettingsAppearanceTableViewCell
UserDefaults.shared.observe(\.customUserInterfaceStyle, options: [.initial, .new]) { [weak cell] defaults, _ in
guard let cell = cell else { return }
switch defaults.customUserInterfaceStyle {
case .unspecified: cell.update(with: .automatic)
case .dark: cell.update(with: .dark)
case .light: cell.update(with: .light)
@unknown default:
assertionFailure()
}
managedObjectContext.performAndWait {
guard let setting = record.object(in: managedObjectContext) else { return }
cell.configure(setting: setting)
}
.store(in: &cell.observations)
cell.delegate = settingsAppearanceTableViewCellDelegate
return cell
case .notification(let objectID, let switchMode):
case .preference(let record, _):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsToggleTableViewCell.self), for: indexPath) as! SettingsToggleTableViewCell
cell.delegate = settingsToggleCellDelegate
managedObjectContext.performAndWait {
guard let setting = record.object(in: managedObjectContext) else { return }
SettingsSection.configureSettingToggle(cell: cell, item: item, setting: setting)
ManagedObjectObserver.observe(object: setting)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { _ in
// do nothing
}, receiveValue: { [weak cell] change in
guard let cell = cell else { return }
guard case .update(let object) = change.changeType,
let setting = object as? Setting else { return }
SettingsSection.configureSettingToggle(cell: cell, item: item, setting: setting)
})
.store(in: &cell.disposeBag)
}
return cell
case .notification(let record, let switchMode):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsToggleTableViewCell.self), for: indexPath) as! SettingsToggleTableViewCell
managedObjectContext.performAndWait {
let setting = managedObjectContext.object(with: objectID) as! Setting
guard let setting = record.object(in: managedObjectContext) else { return }
if let subscription = setting.activeSubscription {
SettingsSection.configureSettingToggle(cell: cell, switchMode: switchMode, subscription: subscription)
}
@ -75,32 +90,12 @@ extension SettingsSection {
}
cell.delegate = settingsToggleCellDelegate
return cell
case .preference(let objectID, _):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsToggleTableViewCell.self), for: indexPath) as! SettingsToggleTableViewCell
cell.delegate = settingsToggleCellDelegate
managedObjectContext.performAndWait {
let setting = managedObjectContext.object(with: objectID) as! Setting
SettingsSection.configureSettingToggle(cell: cell, item: item, setting: setting)
ManagedObjectObserver.observe(object: setting)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { _ in
// do nothing
}, receiveValue: { [weak cell] change in
guard let cell = cell else { return }
guard case .update(let object) = change.changeType,
let setting = object as? Setting else { return }
SettingsSection.configureSettingToggle(cell: cell, item: item, setting: setting)
})
.store(in: &cell.disposeBag)
}
return cell
case .boringZone(let item),
.spicyZone(let item):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsLinkTableViewCell.self), for: indexPath) as! SettingsLinkTableViewCell
cell.update(with: item)
return cell
}
} // end switch
}
}
}
@ -117,8 +112,6 @@ extension SettingsSection {
cell.textLabel?.text = preferenceType.title
switch preferenceType {
case .darkMode:
cell.switchButton.isOn = setting.preferredTrueBlackDarkMode
case .disableAvatarAnimation:
cell.switchButton.isOn = setting.preferredStaticAvatar
case .disableEmojiAnimation:

View File

@ -1,198 +0,0 @@
//
// Item.swift
// Mastodon
//
// Created by sxiaojian on 2021/1/27.
//
import Combine
import CoreData
import CoreDataStack
import Foundation
import MastodonSDK
import DifferenceKit
/// Note: update Equatable when change case
enum Item {
// timeline
case homeTimelineIndex(objectID: NSManagedObjectID, attribute: StatusAttribute)
// thread
case root(statusObjectID: NSManagedObjectID, attribute: StatusAttribute)
case reply(statusObjectID: NSManagedObjectID, attribute: StatusAttribute)
case leaf(statusObjectID: NSManagedObjectID, attribute: StatusAttribute)
case leafBottomLoader(statusObjectID: NSManagedObjectID)
// normal list
case status(objectID: NSManagedObjectID, attribute: StatusAttribute)
// loader
case homeMiddleLoader(upperTimelineIndexAnchorObjectID: NSManagedObjectID)
case publicMiddleLoader(statusID: String)
case topLoader
case bottomLoader
case emptyBottomLoader
case emptyStateHeader(attribute: EmptyStateHeaderAttribute)
// reports
case reportStatus(objectID: NSManagedObjectID, attribute: ReportStatusAttribute)
}
extension Item {
class StatusAttribute {
var isSeparatorLineHidden: Bool
/// is media loaded or not
let isImageLoaded = CurrentValueSubject<Bool, Never>(false)
/// flag for current sensitive content reveal state
///
/// - true: displaying sensitive content
/// - false: displaying content warning overlay
let isRevealing = CurrentValueSubject<Bool, Never>(false)
init(isSeparatorLineHidden: Bool = false) {
self.isSeparatorLineHidden = isSeparatorLineHidden
}
}
class EmptyStateHeaderAttribute: Hashable {
let id = UUID()
let reason: Reason
enum Reason: Equatable {
case noStatusFound
case blocking(name: String?)
case blocked(name: String?)
case suspended(name: String?)
static func == (lhs: Item.EmptyStateHeaderAttribute.Reason, rhs: Item.EmptyStateHeaderAttribute.Reason) -> Bool {
switch (lhs, rhs) {
case (.noStatusFound, noStatusFound): return true
case (.blocking(let nameLeft), blocking(let nameRight)): return nameLeft == nameRight
case (.blocked(let nameLeft), blocked(let nameRight)): return nameLeft == nameRight
case (.suspended(let nameLeft), .suspended(let nameRight)): return nameLeft == nameRight
default: return false
}
}
}
init(reason: Reason) {
self.reason = reason
}
static func == (lhs: Item.EmptyStateHeaderAttribute, rhs: Item.EmptyStateHeaderAttribute) -> Bool {
return lhs.reason == rhs.reason
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
class ReportStatusAttribute: StatusAttribute {
var isSelected: Bool
init(isSeparatorLineHidden: Bool = false, isSelected: Bool = false) {
self.isSelected = isSelected
super.init(isSeparatorLineHidden: isSeparatorLineHidden)
}
}
}
extension Item: Equatable {
static func == (lhs: Item, rhs: Item) -> Bool {
switch (lhs, rhs) {
case (.homeTimelineIndex(let objectIDLeft, _), .homeTimelineIndex(let objectIDRight, _)):
return objectIDLeft == objectIDRight
case (.root(let objectIDLeft, _), .root(let objectIDRight, _)):
return objectIDLeft == objectIDRight
case (.reply(let objectIDLeft, _), .reply(let objectIDRight, _)):
return objectIDLeft == objectIDRight
case (.leaf(let objectIDLeft, _), .leaf(let objectIDRight, _)):
return objectIDLeft == objectIDRight
case (.leafBottomLoader(let objectIDLeft), .leafBottomLoader(let objectIDRight)):
return objectIDLeft == objectIDRight
case (.status(let objectIDLeft, _), .status(let objectIDRight, _)):
return objectIDLeft == objectIDRight
case (.homeMiddleLoader(let upperLeft), .homeMiddleLoader(let upperRight)):
return upperLeft == upperRight
case (.publicMiddleLoader(let upperLeft), .publicMiddleLoader(let upperRight)):
return upperLeft == upperRight
case (.topLoader, .topLoader):
return true
case (.bottomLoader, .bottomLoader):
return true
case (.emptyBottomLoader, .emptyBottomLoader):
return true
case (.emptyStateHeader(let attributeLeft), .emptyStateHeader(let attributeRight)):
return attributeLeft == attributeRight
case (.reportStatus(let objectIDLeft, _), .reportStatus(let objectIDRight, _)):
return objectIDLeft == objectIDRight
default:
return false
}
}
}
extension Item: Hashable {
func hash(into hasher: inout Hasher) {
switch self {
case .homeTimelineIndex(let objectID, _):
hasher.combine(objectID)
case .root(let objectID, _):
hasher.combine(objectID)
case .reply(let objectID, _):
hasher.combine(objectID)
case .leaf(let objectID, _):
hasher.combine(objectID)
case .leafBottomLoader(let objectID):
hasher.combine(objectID)
case .status(let objectID, _):
hasher.combine(objectID)
case .homeMiddleLoader(upperTimelineIndexAnchorObjectID: let upper):
hasher.combine(String(describing: Item.homeMiddleLoader.self))
hasher.combine(upper)
case .publicMiddleLoader(let upper):
hasher.combine(String(describing: Item.publicMiddleLoader.self))
hasher.combine(upper)
case .topLoader:
hasher.combine(String(describing: Item.topLoader.self))
case .bottomLoader:
hasher.combine(String(describing: Item.bottomLoader.self))
case .emptyBottomLoader:
hasher.combine(String(describing: Item.emptyBottomLoader.self))
case .emptyStateHeader(let attribute):
hasher.combine(attribute)
case .reportStatus(let objectID, _):
hasher.combine(objectID)
}
}
}
extension Item: Differentiable { }
extension Item {
var statusObjectItem: StatusObjectItem? {
switch self {
case .homeTimelineIndex(let objectID, _):
return .homeTimelineIndex(objectID: objectID)
case .root(let objectID, _),
.reply(let objectID, _),
.leaf(let objectID, _),
.status(let objectID, _),
.reportStatus(let objectID, _):
return .status(objectID: objectID)
case .leafBottomLoader,
.homeMiddleLoader,
.publicMiddleLoader,
.topLoader,
.bottomLoader,
.emptyBottomLoader,
.emptyStateHeader:
return nil
}
}
}

View File

@ -1,67 +0,0 @@
//
// ReportSection.swift
// Mastodon
//
// Created by ihugo on 2021/4/20.
//
import Combine
import CoreData
import CoreDataStack
import Foundation
import MastodonSDK
import UIKit
import AVKit
import os.log
enum ReportSection: Equatable, Hashable {
case main
}
extension ReportSection {
static func tableViewDiffableDataSource(
for tableView: UITableView,
dependency: ReportViewController,
managedObjectContext: NSManagedObjectContext,
timestampUpdatePublisher: AnyPublisher<Date, Never>
) -> UITableViewDiffableDataSource<ReportSection, Item> {
UITableViewDiffableDataSource(tableView: tableView) {[
weak dependency
] tableView, indexPath, item -> UITableViewCell? in
guard let dependency = dependency else { return UITableViewCell() }
switch item {
case .reportStatus(let objectID, let attribute):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ReportedStatusTableViewCell.self), for: indexPath) as! ReportedStatusTableViewCell
cell.dependency = dependency
let activeMastodonAuthenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value
let requestUserID = activeMastodonAuthenticationBox?.userID ?? ""
managedObjectContext.performAndWait { [weak dependency] in
guard let dependency = dependency else { return }
let status = managedObjectContext.object(with: objectID) as! Status
StatusSection.configure(
cell: cell,
tableView: tableView,
timelineContext: .report,
dependency: dependency,
readableLayoutFrame: tableView.readableContentGuide.layoutFrame,
status: status,
requestUserID: requestUserID,
statusItemAttribute: attribute
)
}
// defalut to select the report status
if attribute.isSelected {
tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none)
} else {
tableView.deselectRow(at: indexPath, animated: false)
}
return cell
default:
return nil
}
}
}
}

View File

@ -0,0 +1,66 @@
//
// StatusItem.swift
// Mastodon
//
// Created by MainasuK on 2022-1-11.
//
import Foundation
import CoreDataStack
import MastodonUI
enum StatusItem: Hashable {
case feed(record: ManagedObjectRecord<Feed>)
case feedLoader(record: ManagedObjectRecord<Feed>)
case status(record: ManagedObjectRecord<Status>)
case thread(Thread)
case topLoader
case bottomLoader
}
extension StatusItem {
enum Thread: Hashable {
case root(context: Context)
case reply(context: Context)
case leaf(context: Context)
public var record: ManagedObjectRecord<Status> {
switch self {
case .root(let threadContext),
.reply(let threadContext),
.leaf(let threadContext):
return threadContext.status
}
}
}
}
extension StatusItem.Thread {
class Context: Hashable {
let status: ManagedObjectRecord<Status>
var displayUpperConversationLink: Bool
var displayBottomConversationLink: Bool
init(
status: ManagedObjectRecord<Status>,
displayUpperConversationLink: Bool = false,
displayBottomConversationLink: Bool = false
) {
self.status = status
self.displayUpperConversationLink = displayUpperConversationLink
self.displayBottomConversationLink = displayBottomConversationLink
}
static func == (lhs: StatusItem.Thread.Context, rhs: StatusItem.Thread.Context) -> Bool {
return lhs.status == rhs.status
&& lhs.displayUpperConversationLink == rhs.displayUpperConversationLink
&& lhs.displayBottomConversationLink == rhs.displayBottomConversationLink
}
func hash(into hasher: inout Hasher) {
hasher.combine(status)
hasher.combine(displayUpperConversationLink)
hasher.combine(displayBottomConversationLink)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -7,10 +7,10 @@
import Foundation
import CoreData
import CoreDataStack
enum UserItem: Hashable {
case follower(objectID: NSManagedObjectID)
case following(objectID: NSManagedObjectID)
case user(record: ManagedObjectRecord<MastodonUser>)
case bottomLoader
case bottomHeader(text: String)
}

View File

@ -19,23 +19,30 @@ enum UserSection: Hashable {
extension UserSection {
static let logger = Logger(subsystem: "StatusSection", category: "logic")
struct Configuration {
weak var userTableViewCellDelegate: UserTableViewCellDelegate?
}
static func tableViewDiffableDataSource(
for tableView: UITableView,
dependency: NeedsDependency,
managedObjectContext: NSManagedObjectContext
static func diffableDataSource(
tableView: UITableView,
context: AppContext,
configuration: Configuration
) -> UITableViewDiffableDataSource<UserSection, UserItem> {
UITableViewDiffableDataSource(tableView: tableView) { [
weak dependency
] tableView, indexPath, item -> UITableViewCell? in
guard let dependency = dependency else { return UITableViewCell() }
tableView.register(UserTableViewCell.self, forCellReuseIdentifier: String(describing: UserTableViewCell.self))
return UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in
switch item {
case .follower(let objectID),
.following(let objectID):
case .user(let record):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: UserTableViewCell.self), for: indexPath) as! UserTableViewCell
managedObjectContext.performAndWait {
let user = managedObjectContext.object(with: objectID) as! MastodonUser
configure(cell: cell, user: user)
context.managedObjectContext.performAndWait {
guard let user = record.object(in: context.managedObjectContext) else { return }
configure(
tableView: tableView,
cell: cell,
viewModel: .init(value: .user(user)),
configuration: configuration
)
}
return cell
case .bottomLoader:
@ -55,10 +62,17 @@ extension UserSection {
extension UserSection {
static func configure(
tableView: UITableView,
cell: UserTableViewCell,
user: MastodonUser
viewModel: UserTableViewCell.ViewModel,
configuration: Configuration
) {
cell.configure(user: user)
cell.configure(
tableView: tableView,
viewModel: viewModel,
delegate: configuration.userTableViewCellDelegate
)
}
}

View File

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

View File

@ -1,40 +0,0 @@
//
// Emojis.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-5-7.
//
import Foundation
import MastodonSDK
import MastodonMeta
protocol EmojiContainer {
var emojisData: Data? { get }
}
// FIXME: `Mastodon.Entity.Account` extension
extension EmojiContainer {
static func encode(emojis: [Mastodon.Entity.Emoji]) -> Data? {
return try? JSONEncoder().encode(emojis)
}
var emojis: [Mastodon.Entity.Emoji]? {
let decoder = JSONDecoder()
return emojisData.flatMap { try? decoder.decode([Mastodon.Entity.Emoji].self, from: $0) }
}
var emojiMeta: MastodonContent.Emojis {
let isAnimated = !UserDefaults.shared.preferredStaticEmoji
var dict = MastodonContent.Emojis()
for emoji in emojis ?? [] {
dict[emoji.shortcode] = isAnimated ? emoji.url : emoji.staticURL
}
return dict
}
}

View File

@ -1,27 +0,0 @@
//
// Fields.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-5-25.
//
import Foundation
import MastodonSDK
protocol FieldContainer {
var fieldsData: Data? { get }
}
extension FieldContainer {
static func encode(fields: [Mastodon.Entity.Field]) -> Data? {
return try? JSONEncoder().encode(fields)
}
var fields: [Mastodon.Entity.Field]? {
let decoder = JSONDecoder()
return fieldsData.flatMap { try? decoder.decode([Mastodon.Entity.Field].self, from: $0) }
}
}

View File

@ -1,74 +0,0 @@
//
// MastodonUser+Property.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-7-20.
//
import Foundation
import CoreDataStack
extension MastodonUser {
var displayNameWithFallback: String {
return !displayName.isEmpty ? displayName : username
}
var acctWithDomain: String {
if !acct.contains("@") {
// Safe concat due to username cannot contains "@"
return username + "@" + domain
} else {
return acct
}
}
var domainFromAcct: String {
if !acct.contains("@") {
return domain
} else {
let domain = acct.split(separator: "@").last
return String(domain!)
}
}
}
extension MastodonUser {
public func headerImageURL() -> URL? {
return URL(string: header)
}
public func headerImageURLWithFallback(domain: String) -> URL {
return URL(string: header) ?? URL(string: "https://\(domain)/headers/original/missing.png")!
}
public func avatarImageURL() -> URL? {
let string = UserDefaults.shared.preferredStaticAvatar ? avatarStatic ?? avatar : avatar
return URL(string: string)
}
public func avatarImageURLWithFallback(domain: String) -> URL {
return avatarImageURL() ?? URL(string: "https://\(domain)/avatars/original/missing.png")!
}
}
extension MastodonUser {
var profileURL: URL {
if let urlString = self.url,
let url = URL(string: urlString) {
return url
} else {
return URL(string: "https://\(self.domain)/@\(username)")!
}
}
var activityItems: [Any] {
var items: [Any] = []
items.append(profileURL)
return items
}
}

View File

@ -9,33 +9,67 @@ 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,
header: entity.header,
headerStatic: entity.headerStatic,
note: entity.note,
url: entity.url,
emojisData: entity.emojis.flatMap { MastodonUser.encode(emojis: $0) },
fieldsData: entity.fields.flatMap { MastodonUser.encode(fields: $0) },
statusesCount: entity.statusesCount,
followingCount: entity.followingCount,
followersCount: entity.followersCount,
locked: entity.locked,
bot: entity.bot,
suspended: entity.suspended,
createdAt: entity.createdAt,
networkDate: networkDate
)
extension MastodonUser {
public var displayNameWithFallback: String {
return !displayName.isEmpty ? displayName : username
}
public var acctWithDomain: String {
if !acct.contains("@") {
// Safe concat due to username cannot contains "@"
return username + "@" + domain
} else {
return acct
}
}
public var domainFromAcct: String {
if !acct.contains("@") {
return domain
} else {
let domain = acct.split(separator: "@").last
return String(domain!)
}
}
}
extension MastodonUser: EmojiContainer { }
extension MastodonUser: FieldContainer { }
extension MastodonUser {
public func headerImageURL() -> URL? {
return URL(string: header)
}
public func headerImageURLWithFallback(domain: String) -> URL {
return URL(string: header) ?? URL(string: "https://\(domain)/headers/original/missing.png")!
}
public func avatarImageURL() -> URL? {
let string = UserDefaults.shared.preferredStaticAvatar ? avatarStatic ?? avatar : avatar
return URL(string: string)
}
public func avatarImageURLWithFallback(domain: String) -> URL {
return avatarImageURL() ?? URL(string: "https://\(domain)/avatars/original/missing.png")!
}
}
extension MastodonUser {
public var profileURL: URL {
if let urlString = self.url,
let url = URL(string: urlString) {
return url
} else {
return URL(string: "https://\(self.domain)/@\(username)")!
}
}
public var activityItems: [Any] {
var items: [Any] = []
items.append(profileURL)
return items
}
}

View File

@ -1,16 +0,0 @@
//
// NotificationType.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021-7-3.
//
import Foundation
import CoreDataStack
import MastodonSDK
extension MastodonNotification {
var notificationType: Mastodon.Entity.Notification.NotificationType {
return Mastodon.Entity.Notification.NotificationType(rawValue: typeRaw) ?? ._other(typeRaw)
}
}

View File

@ -9,67 +9,42 @@ import CoreDataStack
import Foundation
import MastodonSDK
extension Status.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,
emojisData: entity.emojis.flatMap { Status.encode(emojis: $0) },
reblogsCount: NSNumber(value: entity.reblogsCount),
favouritesCount: NSNumber(value: entity.favouritesCount),
repliesCount: entity.repliesCount.flatMap { NSNumber(value: $0) },
url: entity.url ?? entity.uri,
inReplyToID: entity.inReplyToID,
inReplyToAccountID: entity.inReplyToAccountID,
language: entity.language,
text: entity.text,
networkDate: networkDate
)
}
}
extension Status {
enum SensitiveType {
case none
case all
case media(isSensitive: Bool)
}
var sensitiveType: SensitiveType {
let spoilerText = self.spoilerText ?? ""
// cast .all sensitive when has spoiter text
if !spoilerText.isEmpty {
return .all
}
if let firstAttachment = mediaAttachments?.first {
if let firstAttachment = attachments.first {
// cast .media when has non audio media
if firstAttachment.type != .audio {
if firstAttachment.kind != .audio {
return .media(isSensitive: sensitive)
} else {
return .none
}
}
// not sensitive
return .none
}
}
extension Status {
var authorForUserProvider: MastodonUser {
let author = (reblog ?? self).author
return author
}
}
//extension Status {
// var authorForUserProvider: MastodonUser {
// let author = (reblog ?? self).author
// return author
// }
//}
//
extension Status {
var statusURL: URL {
if let urlString = self.url,
@ -80,7 +55,7 @@ extension Status {
return URL(string: "https://\(self.domain)/web/statuses/\(self.id)")!
}
}
var activityItems: [Any] {
var items: [Any] = []
items.append(self.statusURL)
@ -88,11 +63,15 @@ extension Status {
}
}
extension Status: EmojiContainer { }
//extension Status {
// var visibilityEnum: Mastodon.Entity.Status.Visibility? {
// return visibility.flatMap { Mastodon.Entity.Status.Visibility(rawValue: $0) }
// }
//}
extension Status {
var visibilityEnum: Mastodon.Entity.Status.Visibility? {
return visibility.flatMap { Mastodon.Entity.Status.Visibility(rawValue: $0) }
var asRecord: ManagedObjectRecord<Status> {
return .init(objectID: self.objectID)
}
}

View File

@ -10,6 +10,7 @@ import Combine
import Alamofire
import AlamofireImage
import FLAnimatedImage
import UIKit
private enum FLAnimatedImageViewAssociatedKeys {
static var activeAvatarRequestURL = "FLAnimatedImageViewAssociatedKeys.activeAvatarRequestURL"
@ -36,7 +37,12 @@ extension FLAnimatedImageView {
}
}
func setImage(url: URL?, placeholder: UIImage?, scaleToSize: CGSize?) {
func setImage(
url: URL?,
placeholder: UIImage?,
scaleToSize: CGSize?,
completion: ((UIImage?) -> Void)? = nil
) {
// cancel task
activeAvatarRequestURL = nil
avatarRequestCancellable?.cancel()
@ -64,17 +70,17 @@ extension FLAnimatedImageView {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
if self.activeAvatarRequestURL == url {
if let animatedImage = animatedImage {
self.animatedImage = animatedImage
} else {
self.image = image
}
guard self.activeAvatarRequestURL == url else { return }
if let animatedImage = animatedImage {
self.animatedImage = animatedImage
} else {
self.image = image
}
completion?(image)
}
}
case .failure:
break
completion?(nil)
}
}
}

View File

@ -7,6 +7,8 @@
import Foundation
import MastodonSDK
import MastodonAsset
import MastodonLocalization
extension Mastodon.API.Subscriptions.Policy {
var title: String {

View File

@ -7,6 +7,8 @@
import Foundation
import MastodonSDK
import MastodonAsset
import MastodonLocalization
extension Mastodon.Entity.Error.Detail: LocalizedError {

View File

@ -8,6 +8,8 @@
import Foundation
import MastodonSDK
import UIKit
import MastodonAsset
import MastodonLocalization
extension Mastodon.Entity.Notification.NotificationType {
public var color: UIColor {

View File

@ -16,3 +16,15 @@ extension Mastodon.Entity.Tag: Hashable {
return lhs.name == rhs.name
}
}
extension Mastodon.Entity.Tag {
/// the sum of recent 2 days
public var talkingPeopleCount: Int? {
return history?
.prefix(2)
.compactMap { Int($0.accounts) }
.reduce(0, +)
}
}

View File

@ -6,6 +6,8 @@
//
import UIKit
import MastodonAsset
import MastodonLocalization
extension UITableView {

View File

@ -68,8 +68,3 @@ extension UIView {
}
}
extension UIView {
static var isZoomedMode: Bool {
return UIScreen.main.scale != UIScreen.main.nativeScale
}
}

View File

@ -1,272 +0,0 @@
// swiftlint:disable all
// Generated using SwiftGen https://github.com/SwiftGen/SwiftGen
#if os(macOS)
import AppKit
#elseif os(iOS)
import UIKit
#elseif os(tvOS) || os(watchOS)
import UIKit
#endif
// 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
// MARK: - Asset Catalogs
// swiftlint:disable identifier_name line_length nesting type_body_length type_name
internal enum Asset {
internal static let accentColor = ColorAsset(name: "AccentColor")
internal enum Asset {
internal static let email = ImageAsset(name: "Asset/email")
internal static let friends = ImageAsset(name: "Asset/friends")
internal static let mastodonTextLogo = ImageAsset(name: "Asset/mastodon.text.logo")
}
internal enum Circles {
internal static let plusCircleFill = ImageAsset(name: "Circles/plus.circle.fill")
internal static let plusCircle = ImageAsset(name: "Circles/plus.circle")
}
internal enum Colors {
internal enum Border {
internal static let composePoll = ColorAsset(name: "Colors/Border/compose.poll")
internal static let searchCard = ColorAsset(name: "Colors/Border/searchCard")
internal static let status = ColorAsset(name: "Colors/Border/status")
}
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 inactive = ColorAsset(name: "Colors/Button/inactive")
}
internal enum Icon {
internal static let plus = ColorAsset(name: "Colors/Icon/plus")
}
internal enum Label {
internal static let primary = ColorAsset(name: "Colors/Label/primary")
internal static let primaryReverse = ColorAsset(name: "Colors/Label/primary.reverse")
internal static let secondary = ColorAsset(name: "Colors/Label/secondary")
internal static let tertiary = ColorAsset(name: "Colors/Label/tertiary")
}
internal enum Notification {
internal static let favourite = ColorAsset(name: "Colors/Notification/favourite")
internal static let mention = ColorAsset(name: "Colors/Notification/mention")
internal static let reblog = ColorAsset(name: "Colors/Notification/reblog")
}
internal enum Poll {
internal static let disabled = ColorAsset(name: "Colors/Poll/disabled")
}
internal enum Shadow {
internal static let searchCard = ColorAsset(name: "Colors/Shadow/SearchCard")
}
internal enum Slider {
internal static let track = ColorAsset(name: "Colors/Slider/track")
}
internal enum TextField {
internal static let background = ColorAsset(name: "Colors/TextField/background")
internal static let invalid = ColorAsset(name: "Colors/TextField/invalid")
internal static let valid = ColorAsset(name: "Colors/TextField/valid")
}
internal static let alertYellow = ColorAsset(name: "Colors/alert.yellow")
internal static let badgeBackground = ColorAsset(name: "Colors/badge.background")
internal static let battleshipGrey = ColorAsset(name: "Colors/battleshipGrey")
internal static let brandBlue = ColorAsset(name: "Colors/brand.blue")
internal static let brandBlueDarken20 = ColorAsset(name: "Colors/brand.blue.darken.20")
internal static let dangerBorder = ColorAsset(name: "Colors/danger.border")
internal static let danger = ColorAsset(name: "Colors/danger")
internal static let disabled = ColorAsset(name: "Colors/disabled")
internal static let inactive = ColorAsset(name: "Colors/inactive")
internal static let mediaTypeIndicotor = ColorAsset(name: "Colors/media.type.indicotor")
internal static let successGreen = ColorAsset(name: "Colors/success.green")
internal static let systemOrange = ColorAsset(name: "Colors/system.orange")
}
internal enum Connectivity {
internal static let photoFillSplit = ImageAsset(name: "Connectivity/photo.fill.split")
}
internal enum Human {
internal static let faceSmilingAdaptive = ImageAsset(name: "Human/face.smiling.adaptive")
}
internal enum Scene {
internal enum Onboarding {
internal static let avatarPlaceholder = ImageAsset(name: "Scene/Onboarding/avatar.placeholder")
internal static let navigationBackButtonBackground = ColorAsset(name: "Scene/Onboarding/navigation.back.button.background")
internal static let navigationBackButtonBackgroundHighlighted = ColorAsset(name: "Scene/Onboarding/navigation.back.button.background.highlighted")
internal static let navigationNextButtonBackground = ColorAsset(name: "Scene/Onboarding/navigation.next.button.background")
internal static let navigationNextButtonBackgroundHighlighted = ColorAsset(name: "Scene/Onboarding/navigation.next.button.background.highlighted")
internal static let onboardingBackground = ColorAsset(name: "Scene/Onboarding/onboarding.background")
internal static let searchBarBackground = ColorAsset(name: "Scene/Onboarding/search.bar.background")
internal static let textFieldBackground = ColorAsset(name: "Scene/Onboarding/textField.background")
}
internal enum Profile {
internal enum Banner {
internal static let bioEditBackgroundGray = ColorAsset(name: "Scene/Profile/Banner/bio.edit.background.gray")
internal static let nameEditBackgroundGray = ColorAsset(name: "Scene/Profile/Banner/name.edit.background.gray")
internal static let usernameGray = ColorAsset(name: "Scene/Profile/Banner/username.gray")
}
}
internal enum Sidebar {
internal static let logo = ImageAsset(name: "Scene/Sidebar/logo")
}
internal enum Welcome {
internal enum Illustration {
internal static let backgroundCyan = ColorAsset(name: "Scene/Welcome/illustration/background.cyan")
internal static let cloudBaseExtend = ImageAsset(name: "Scene/Welcome/illustration/cloud.base.extend")
internal static let cloudBase = ImageAsset(name: "Scene/Welcome/illustration/cloud.base")
internal static let elephantOnAirplaneWithContrail = ImageAsset(name: "Scene/Welcome/illustration/elephant.on.airplane.with.contrail")
internal static let elephantThreeOnGrassExtend = ImageAsset(name: "Scene/Welcome/illustration/elephant.three.on.grass.extend")
internal static let elephantThreeOnGrass = ImageAsset(name: "Scene/Welcome/illustration/elephant.three.on.grass")
internal static let elephantThreeOnGrassWithTreeThree = ImageAsset(name: "Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three")
internal static let elephantThreeOnGrassWithTreeTwo = ImageAsset(name: "Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two")
}
internal static let mastodonLogoBlack = ImageAsset(name: "Scene/Welcome/mastodon.logo.black")
internal static let mastodonLogoBlackLarge = ImageAsset(name: "Scene/Welcome/mastodon.logo.black.large")
internal static let mastodonLogo = ImageAsset(name: "Scene/Welcome/mastodon.logo")
internal static let mastodonLogoLarge = ImageAsset(name: "Scene/Welcome/mastodon.logo.large")
internal static let signInButtonBackground = ColorAsset(name: "Scene/Welcome/sign.in.button.background")
}
}
internal enum Settings {
internal static let blackAuto = ImageAsset(name: "Settings/black.auto")
internal static let black = ImageAsset(name: "Settings/black")
internal static let darkAuto = ImageAsset(name: "Settings/dark.auto")
internal static let dark = ImageAsset(name: "Settings/dark")
internal static let light = ImageAsset(name: "Settings/light")
}
internal enum Theme {
internal enum Mastodon {
internal static let composeToolbarBackground = ColorAsset(name: "Theme/Mastodon/compose.toolbar.background")
internal static let contentWarningOverlayBackground = ColorAsset(name: "Theme/Mastodon/content.warning.overlay.background")
internal static let navigationBarBackground = ColorAsset(name: "Theme/Mastodon/navigation.bar.background")
internal static let profileFieldCollectionViewBackground = ColorAsset(name: "Theme/Mastodon/profile.field.collection.view.background")
internal static let secondaryGroupedSystemBackground = ColorAsset(name: "Theme/Mastodon/secondary.grouped.system.background")
internal static let secondarySystemBackground = ColorAsset(name: "Theme/Mastodon/secondary.system.background")
internal static let sidebarBackground = ColorAsset(name: "Theme/Mastodon/sidebar.background")
internal static let systemBackground = ColorAsset(name: "Theme/Mastodon/system.background")
internal static let systemElevatedBackground = ColorAsset(name: "Theme/Mastodon/system.elevated.background")
internal static let systemGroupedBackground = ColorAsset(name: "Theme/Mastodon/system.grouped.background")
internal static let tabBarBackground = ColorAsset(name: "Theme/Mastodon/tab.bar.background")
internal static let tableViewCellBackground = ColorAsset(name: "Theme/Mastodon/table.view.cell.background")
internal static let tableViewCellSelectionBackground = ColorAsset(name: "Theme/Mastodon/table.view.cell.selection.background")
internal static let tertiarySystemBackground = ColorAsset(name: "Theme/Mastodon/tertiary.system.background")
internal static let tertiarySystemGroupedBackground = ColorAsset(name: "Theme/Mastodon/tertiary.system.grouped.background")
internal static let notificationStatusBorderColor = ColorAsset(name: "Theme/Mastodon/notification.status.border.color")
internal static let separator = ColorAsset(name: "Theme/Mastodon/separator")
internal static let tabBarItemInactiveIconColor = ColorAsset(name: "Theme/Mastodon/tab.bar.item.inactive.icon.color")
}
internal enum System {
internal static let composeToolbarBackground = ColorAsset(name: "Theme/system/compose.toolbar.background")
internal static let contentWarningOverlayBackground = ColorAsset(name: "Theme/system/content.warning.overlay.background")
internal static let navigationBarBackground = ColorAsset(name: "Theme/system/navigation.bar.background")
internal static let profileFieldCollectionViewBackground = ColorAsset(name: "Theme/system/profile.field.collection.view.background")
internal static let secondaryGroupedSystemBackground = ColorAsset(name: "Theme/system/secondary.grouped.system.background")
internal static let secondarySystemBackground = ColorAsset(name: "Theme/system/secondary.system.background")
internal static let sidebarBackground = ColorAsset(name: "Theme/system/sidebar.background")
internal static let systemBackground = ColorAsset(name: "Theme/system/system.background")
internal static let systemElevatedBackground = ColorAsset(name: "Theme/system/system.elevated.background")
internal static let systemGroupedBackground = ColorAsset(name: "Theme/system/system.grouped.background")
internal static let tabBarBackground = ColorAsset(name: "Theme/system/tab.bar.background")
internal static let tableViewCellBackground = ColorAsset(name: "Theme/system/table.view.cell.background")
internal static let tableViewCellSelectionBackground = ColorAsset(name: "Theme/system/table.view.cell.selection.background")
internal static let tertiarySystemBackground = ColorAsset(name: "Theme/system/tertiary.system.background")
internal static let tertiarySystemGroupedBackground = ColorAsset(name: "Theme/system/tertiary.system.grouped.background")
internal static let notificationStatusBorderColor = ColorAsset(name: "Theme/system/notification.status.border.color")
internal static let separator = ColorAsset(name: "Theme/system/separator")
internal static let tabBarItemInactiveIconColor = ColorAsset(name: "Theme/system/tab.bar.item.inactive.icon.color")
}
}
}
// swiftlint:enable identifier_name line_length nesting type_body_length type_name
// MARK: - Implementation Details
internal final class ColorAsset {
internal fileprivate(set) var name: String
#if os(macOS)
internal typealias Color = NSColor
#elseif os(iOS) || os(tvOS) || os(watchOS)
internal typealias Color = UIColor
#endif
@available(iOS 11.0, tvOS 11.0, watchOS 4.0, macOS 10.13, *)
internal private(set) lazy var color: Color = {
guard let color = Color(asset: self) else {
fatalError("Unable to load color asset named \(name).")
}
return color
}()
fileprivate init(name: String) {
self.name = name
}
}
internal extension ColorAsset.Color {
@available(iOS 11.0, tvOS 11.0, watchOS 4.0, macOS 10.13, *)
convenience init?(asset: ColorAsset) {
let bundle = BundleToken.bundle
#if os(iOS) || os(tvOS)
self.init(named: asset.name, in: bundle, compatibleWith: nil)
#elseif os(macOS)
self.init(named: NSColor.Name(asset.name), bundle: bundle)
#elseif os(watchOS)
self.init(named: asset.name)
#endif
}
}
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 = {
#if SWIFT_PACKAGE
return Bundle.module
#else
return Bundle(for: BundleToken.self)
#endif
}()
}
// swiftlint:enable convenience_type

View File

@ -0,0 +1,12 @@
// Generated using Sourcery 1.6.1 https://github.com/krzysztofzablocki/Sourcery
// DO NOT EDIT
// sourcery:inline:NotificationTableViewCellDelegate.AutoGenerateProtocolDelegate
notificationView(_ notificationView: NotificationView, menuButton button: UIButton, didSelectAction action: NotificationView.AuthorMenuAction, menuContext: NotificationView.AuthorMenuContext)
notificationView(_ notificationView: NotificationView, statusView: StatusView, authorAvatarButtonDidPressed button: AvatarButton)
notificationView(_ notificationView: NotificationView, statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta)
notificationView(_ notificationView: NotificationView, statusView: StatusView, actionToolbarContainer: ActionToolbarContainer, buttonDidPressed button: UIButton, action: ActionToolbarContainer.Action)
notificationView(_ notificationView: NotificationView, quoteStatusView: StatusView, authorAvatarButtonDidPressed button: AvatarButton)
notificationView(_ notificationView: NotificationView, quoteStatusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta)
// sourcery:end

View File

@ -0,0 +1,30 @@
// Generated using Sourcery 1.6.1 https://github.com/krzysztofzablocki/Sourcery
// DO NOT EDIT
// sourcery:inline:NotificationViewContainerTableViewCell.AutoGenerateProtocolRelayDelegate
func notificationView(_ notificationView: NotificationView, menuButton button: UIButton, didSelectAction action: NotificationView.AuthorMenuAction, menuContext: NotificationView.AuthorMenuContext) {
notificationView(notificationView, menuButton: button, didSelectAction: action, menuContext: menuContext)
}
func notificationView(_ notificationView: NotificationView, statusView: StatusView, authorAvatarButtonDidPressed button: AvatarButton) {
notificationView(notificationView, statusView: statusView, authorAvatarButtonDidPressed: button)
}
func notificationView(_ notificationView: NotificationView, statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) {
notificationView(notificationView, statusView: statusView, metaText: metaText, didSelectMeta: meta)
}
func notificationView(_ notificationView: NotificationView, statusView: StatusView, actionToolbarContainer: ActionToolbarContainer, buttonDidPressed button: UIButton, action: ActionToolbarContainer.Action) {
notificationView(notificationView, statusView: statusView, actionToolbarContainer: actionToolbarContainer, buttonDidPressed: button, action: action)
}
func notificationView(_ notificationView: NotificationView, quoteStatusView: StatusView, authorAvatarButtonDidPressed button: AvatarButton) {
notificationView(notificationView, quoteStatusView: quoteStatusView, authorAvatarButtonDidPressed: button)
}
func notificationView(_ notificationView: NotificationView, quoteStatusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) {
notificationView(notificationView, quoteStatusView: quoteStatusView, metaText: metaText, didSelectMeta: meta)
}
// sourcery:end

View File

@ -0,0 +1,20 @@
// Generated using Sourcery 1.6.1 https://github.com/krzysztofzablocki/Sourcery
// DO NOT EDIT
// sourcery:inline:FollowingListViewController.AutoGenerateTableViewDelegate
// Generated using Sourcery
// DO NOT EDIT
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
aspectTableView(tableView, didSelectRowAt: indexPath)
}
// sourcery:end

File diff suppressed because it is too large Load Diff

View File

@ -6,10 +6,12 @@
//
import Foundation
import MastodonSDK
import CoreDataStack
import MastodonSDK
import MastodonUI
struct MastodonAuthenticationBox {
struct MastodonAuthenticationBox: UserIdentifier {
let authenticationRecord: ManagedObjectRecord<MastodonAuthentication>
let domain: String
let userID: MastodonUser.ID
let appAuthorization: Mastodon.API.OAuth.Authorization

View File

@ -30,7 +30,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>90</string>
<string>96</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationQueriesSchemes</key>

View File

@ -0,0 +1,34 @@
//
// MastodonEmojis.swift
// MastodonEmojis
//
// Created by Cirno MainasuK on 2021-9-2.
// Copyright © 2021 Twidere. All rights reserved.
//
import Foundation
import CoreDataStack
import MastodonSDK
import MastodonMeta
extension MastodonEmoji {
public convenience init(emoji: Mastodon.Entity.Emoji) {
self.init(
code: emoji.shortcode,
url: emoji.url,
staticURL: emoji.staticURL,
visibleInPicker: emoji.visibleInPicker,
category: emoji.category
)
}
}
extension Collection where Element == MastodonEmoji {
public var asDictionary: MastodonContent.Emojis {
var dictionary: MastodonContent.Emojis = [:]
for emoji in self {
dictionary[emoji.code] = emoji.url
}
return dictionary
}
}

View File

@ -0,0 +1,21 @@
//
// MastodonField.swift
// TwidereX
//
// Created by Cirno MainasuK on 2021-9-18.
// Copyright © 2021 Twidere. All rights reserved.
//
import Foundation
import CoreDataStack
import MastodonSDK
extension MastodonField {
public convenience init(field: Mastodon.Entity.Field) {
self.init(
name: field.name,
value: field.value,
verifiedAt: field.verifiedAt
)
}
}

View File

@ -0,0 +1,21 @@
//
// MastodonMention.swift
// Mastodon
//
// Created by MainasuK on 2022-1-17.
//
import Foundation
import CoreDataStack
import MastodonSDK
extension MastodonMention {
public convenience init(mention: Mastodon.Entity.Mention) {
self.init(
id: mention.id,
username: mention.username,
acct: mention.acct,
url: mention.url
)
}
}

View File

@ -0,0 +1,39 @@
//
// MastodonUser+Property.swift
// Mastodon
//
// Created by MainasuK on 2022-1-11.
//
import Foundation
import CoreDataStack
import MastodonSDK
extension MastodonUser.Property {
init(entity: Mastodon.Entity.Account, domain: String, networkDate: Date) {
self.init(
identifier: entity.id + "@" + domain,
domain: domain,
id: entity.id,
acct: entity.acct,
username: entity.username,
displayName: entity.displayName,
avatar: entity.avatar,
avatarStatic: entity.avatarStatic,
header: entity.header,
headerStatic: entity.headerStatic,
note: entity.note,
url: entity.url,
statusesCount: Int64(entity.statusesCount),
followingCount: Int64(entity.followingCount),
followersCount: Int64(entity.followersCount),
locked: entity.locked,
bot: entity.bot ?? false,
suspended: entity.suspended ?? false,
createdAt: entity.createdAt,
updatedAt: networkDate,
emojis: entity.mastodonEmojis,
fields: entity.mastodonFields
)
}
}

View File

@ -0,0 +1,29 @@
//
// Notification+Property.swift
// Mastodon
//
// Created by MainasuK on 2022-1-21.
//
import Foundation
import CoreDataStack
import MastodonSDK
import class CoreDataStack.Notification
extension Notification.Property {
public init(
entity: Mastodon.Entity.Notification,
domain: String,
userID: MastodonUser.ID,
networkDate: Date
) {
self.init(
id: entity.id,
typeRaw: entity.type.rawValue,
domain: domain,
userID: userID,
createAt: entity.createdAt,
updatedAt: networkDate
)
}
}

View File

@ -0,0 +1,30 @@
//
// MastodonPoll.swift
//
//
// Created by MainasuK on 2021-12-9.
//
import Foundation
import CoreDataStack
import MastodonSDK
extension Poll.Property {
public init(
entity: Mastodon.Entity.Poll,
domain: String,
networkDate: Date
) {
self.init(
domain: domain,
id: entity.id,
expiresAt: entity.expiresAt,
expired: entity.expired,
multiple: entity.multiple,
votesCount: Int64(entity.votesCount),
votersCount: Int64(entity.votersCount ?? 0),
createdAt: networkDate,
updatedAt: networkDate
)
}
}

View File

@ -0,0 +1,26 @@
//
// MastodonPollOption+Property.swift
//
//
// Created by MainasuK on 2021-12-9.
//
import Foundation
import MastodonSDK
import CoreDataStack
extension PollOption.Property {
public init(
index: Int,
entity: Mastodon.Entity.Poll.Option,
networkDate: Date
) {
self.init(
index: Int64(index),
title: entity.title,
votesCount: Int64(entity.votesCount ?? 0),
createdAt: networkDate,
updatedAt: networkDate
)
}
}

View File

@ -0,0 +1,91 @@
//
// Status+Property.swift
// Mastodon
//
// Created by MainasuK on 2022-1-11.
//
import Foundation
import CoreGraphics
import CoreDataStack
import MastodonSDK
extension Status.Property {
init(entity: Mastodon.Entity.Status, domain: String, networkDate: Date) {
self.init(
identifier: entity.id + "@" + domain,
domain: domain,
id: entity.id,
uri: entity.uri,
createdAt: entity.createdAt,
content: entity.content ?? "",
visibility: entity.mastodonVisibility,
sensitive: entity.sensitive ?? false,
spoilerText: entity.spoilerText,
reblogsCount: Int64(entity.reblogsCount),
favouritesCount: Int64(entity.favouritesCount),
repliesCount: Int64(entity.repliesCount ?? 0),
url: entity.url,
inReplyToID: entity.inReplyToID,
inReplyToAccountID: entity.inReplyToAccountID,
language: entity.language,
text: entity.text,
updatedAt: networkDate,
deletedAt: nil,
attachments: entity.mastodonAttachments,
emojis: entity.mastodonEmojis,
mentions: entity.mastodonMentions
)
}
}
extension Mastodon.Entity.Status {
public var mastodonVisibility: MastodonVisibility {
let rawValue = visibility?.rawValue ?? ""
return MastodonVisibility(rawValue: rawValue) ?? ._other(rawValue)
}
}
extension Mastodon.Entity.Status {
public var mastodonAttachments: [MastodonAttachment] {
guard let mediaAttachments = mediaAttachments else { return [] }
let attachments = mediaAttachments.compactMap { media -> MastodonAttachment? in
guard let kind = media.attachmentKind,
let meta = media.meta,
let original = meta.original,
let width = original.width, // audio has width/height
let height = original.height
else { return nil }
let durationMS: Int? = original.duration.flatMap { Int($0 * 1000) }
return MastodonAttachment(
id: media.id,
kind: kind,
size: CGSize(width: width, height: height),
focus: nil, // TODO:
blurhash: media.blurhash,
assetURL: media.url,
previewURL: media.previewURL,
textURL: media.textURL,
durationMS: durationMS,
altDescription: media.description
)
}
return attachments
}
}
extension Mastodon.Entity.Attachment {
public var attachmentKind: MastodonAttachment.Kind? {
switch type {
case .unknown: return nil
case .image: return .image
case .gifv: return .gifv
case .video: return .video
case .audio: return .audio
case ._other: return nil
}
}
}

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