Merge branch 'develop' into feature/welcome-illustration

# Conflicts:
#	Mastodon.xcodeproj/project.pbxproj
#	Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist
#	Mastodon/Extension/MastodonSDK/Mastodon+Entidy+ErrorDetailReason.swift
This commit is contained in:
CMK 2021-03-16 15:42:15 +08:00
commit f0b08e2b56
137 changed files with 7609 additions and 1638 deletions

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17709" systemVersion="20D74" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17709" systemVersion="20D5029f" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Application" representedClassName=".Application" syncable="YES">
<attribute name="identifier" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String"/>
@ -16,7 +16,7 @@
<attribute name="index" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="metaData" optional="YES" attributeType="Binary"/>
<attribute name="previewRemoteURL" optional="YES" attributeType="String"/>
<attribute name="previewURL" attributeType="String"/>
<attribute name="previewURL" optional="YES" attributeType="String"/>
<attribute name="remoteURL" optional="YES" attributeType="String"/>
<attribute name="textURL" optional="YES" attributeType="String"/>
<attribute name="typeRaw" attributeType="String"/>
@ -83,6 +83,8 @@
<relationship name="pinnedToot" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="pinnedBy" inverseEntity="Toot"/>
<relationship name="reblogged" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="rebloggedBy" inverseEntity="Toot"/>
<relationship name="toots" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="author" inverseEntity="Toot"/>
<relationship name="votePollOptions" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="PollOption" inverseName="votedBy" inverseEntity="PollOption"/>
<relationship name="votePolls" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Poll" inverseName="votedBy" inverseEntity="Poll"/>
</entity>
<entity name="Mention" representedClassName=".Mention" syncable="YES">
<attribute name="acct" attributeType="String"/>
@ -93,6 +95,28 @@
<attribute name="username" attributeType="String"/>
<relationship name="toot" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="mentions" inverseEntity="Toot"/>
</entity>
<entity name="Poll" representedClassName=".Poll" syncable="YES">
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="expired" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="expiresAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="id" attributeType="String"/>
<attribute name="multiple" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="votersCount" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="votesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="options" toMany="YES" deletionRule="Nullify" destinationEntity="PollOption" inverseName="poll" inverseEntity="PollOption"/>
<relationship name="toot" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="poll" inverseEntity="Toot"/>
<relationship name="votedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="votePolls" inverseEntity="MastodonUser"/>
</entity>
<entity name="PollOption" representedClassName=".PollOption" syncable="YES">
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="index" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="title" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="votesCount" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="poll" maxCount="1" deletionRule="Nullify" destinationEntity="Poll" inverseName="options" inverseEntity="Poll"/>
<relationship name="votedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="votePollOptions" inverseEntity="MastodonUser"/>
</entity>
<entity name="Tag" representedClassName=".Tag" syncable="YES">
<attribute name="createAt" attributeType="Date" defaultDateTimeInterval="631123200" usesScalarValueType="NO"/>
<attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/>
@ -128,9 +152,10 @@
<relationship name="favouritedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="favourite" inverseEntity="MastodonUser"/>
<relationship name="homeTimelineIndexes" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="HomeTimelineIndex" inverseName="toot" inverseEntity="HomeTimelineIndex"/>
<relationship name="mediaAttachments" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Attachment" inverseName="toot" inverseEntity="Attachment"/>
<relationship name="mentions" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Mention" inverseName="toot" inverseEntity="Mention"/>
<relationship name="mentions" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Mention" inverseName="toot" inverseEntity="Mention"/>
<relationship name="mutedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="muted" inverseEntity="MastodonUser"/>
<relationship name="pinnedBy" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="pinnedToot" inverseEntity="MastodonUser"/>
<relationship name="poll" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="Poll" inverseName="toot" inverseEntity="Poll"/>
<relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="reblogFrom" inverseEntity="Toot"/>
<relationship name="reblogFrom" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="reblog" inverseEntity="Toot"/>
<relationship name="rebloggedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="reblogged" inverseEntity="MastodonUser"/>
@ -138,14 +163,16 @@
</entity>
<elements>
<element name="Application" positionX="160" positionY="192" width="128" height="104"/>
<element name="Attachment" positionX="72" positionY="162" width="128" height="254"/>
<element name="Emoji" positionX="45" positionY="135" width="128" height="149"/>
<element name="History" positionX="27" positionY="126" width="128" height="119"/>
<element name="HomeTimelineIndex" positionX="0" positionY="0" width="128" height="134"/>
<element name="MastodonAuthentication" positionX="18" positionY="162" width="128" height="209"/>
<element name="MastodonUser" positionX="0" positionY="0" width="128" height="284"/>
<element name="MastodonUser" positionX="0" positionY="0" width="128" height="314"/>
<element name="Mention" positionX="9" positionY="108" width="128" height="134"/>
<element name="Poll" positionX="72" positionY="162" width="128" height="194"/>
<element name="PollOption" positionX="81" positionY="171" width="128" height="134"/>
<element name="Tag" positionX="18" positionY="117" width="128" height="119"/>
<element name="Toot" positionX="0" positionY="0" width="128" height="524"/>
<element name="Attachment" positionX="72" positionY="162" width="128" height="14"/>
<element name="Toot" positionX="0" positionY="0" width="128" height="539"/>
</elements>
</model>

View File

@ -24,7 +24,7 @@ public final class Application: NSManagedObject {
public extension Application {
override func awakeFromInsert() {
super.awakeFromInsert()
identifier = UUID()
setPrimitiveValue(UUID(), forKey: #keyPath(Application.identifier))
}
@discardableResult

View File

@ -15,7 +15,7 @@ public final class Attachment: NSManagedObject {
@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 previewURL: String?
@NSManaged public private(set) var remoteURL: String?
@NSManaged public private(set) var metaData: Data?
@ -36,7 +36,7 @@ public extension Attachment {
override func awakeFromInsert() {
super.awakeFromInsert()
createdAt = Date()
setPrimitiveValue(Date(), forKey: #keyPath(Attachment.createdAt))
}
@discardableResult
@ -80,7 +80,7 @@ public extension Attachment {
public let typeRaw: String
public let url: String
public let previewURL: String
public let previewURL: String?
public let remoteURL: String?
public let metaData: Data?
public let textURL: String?
@ -95,7 +95,7 @@ public extension Attachment {
id: Attachment.ID,
typeRaw: String,
url: String,
previewURL: String,
previewURL: String?,
remoteURL: String?,
metaData: Data?,
textURL: String?,

View File

@ -26,7 +26,7 @@ public final class Emoji: NSManagedObject {
public extension Emoji {
override func awakeFromInsert() {
super.awakeFromInsert()
identifier = UUID()
setPrimitiveValue(UUID(), forKey: #keyPath(Emoji.identifier))
}
@discardableResult

View File

@ -24,7 +24,7 @@ public final class History: NSManagedObject {
public extension History {
override func awakeFromInsert() {
super.awakeFromInsert()
identifier = UUID()
setPrimitiveValue(UUID(), forKey: #keyPath(History.identifier))
}
@discardableResult

View File

@ -36,12 +36,12 @@ extension MastodonAuthentication {
public override func awakeFromInsert() {
super.awakeFromInsert()
identifier = UUID()
setPrimitiveValue(UUID(), forKey: #keyPath(MastodonAuthentication.identifier))
let now = Date()
createdAt = now
updatedAt = now
activedAt = now
setPrimitiveValue(now, forKey: #keyPath(MastodonAuthentication.createdAt))
setPrimitiveValue(now, forKey: #keyPath(MastodonAuthentication.updatedAt))
setPrimitiveValue(now, forKey: #keyPath(MastodonAuthentication.activedAt))
}
@discardableResult

View File

@ -37,6 +37,8 @@ final public class MastodonUser: NSManagedObject {
@NSManaged public private(set) var reblogged: Set<Toot>?
@NSManaged public private(set) var muted: Set<Toot>?
@NSManaged public private(set) var bookmarked: Set<Toot>?
@NSManaged public private(set) var votePollOptions: Set<PollOption>?
@NSManaged public private(set) var votePolls: Set<Poll>?
}

View File

@ -25,7 +25,8 @@ public final class Mention: NSManagedObject {
public extension Mention {
override func awakeFromInsert() {
super.awakeFromInsert()
identifier = UUID()
setPrimitiveValue(UUID(), forKey: #keyPath(Mention.identifier))
}
@discardableResult

View File

@ -0,0 +1,145 @@
//
// 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 toot: Toot
// 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

@ -0,0 +1,98 @@
//
// 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

@ -23,13 +23,14 @@ public final class Tag: NSManagedObject {
@NSManaged public private(set) var histories: Set<History>?
}
public extension Tag {
override func awakeFromInsert() {
extension Tag {
public override func awakeFromInsert() {
super.awakeFromInsert()
identifier = UUID()
setPrimitiveValue(UUID(), forKey: #keyPath(Tag.identifier))
}
@discardableResult
static func insert(
public static func insert(
into context: NSManagedObjectContext,
property: Property
) -> Tag {
@ -43,8 +44,8 @@ public extension Tag {
}
}
public extension Tag {
struct Property {
extension Tag {
public struct Property {
public let name: String
public let url: String
public let histories: [History]?

View File

@ -48,6 +48,7 @@ public final class Toot: NSManagedObject {
// one-to-one relastionship
@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<Toot>?
@ -69,6 +70,7 @@ public extension Toot {
author: MastodonUser,
reblog: Toot?,
application: Application?,
poll: Poll?,
mentions: [Mention]?,
emojis: [Emoji]?,
tags: [Tag]?,
@ -109,6 +111,7 @@ public extension Toot {
toot.reblog = reblog
toot.pinnedBy = pinnedBy
toot.poll = poll
if let mentions = mentions {
toot.mutableSetValue(forKey: #keyPath(Toot.mentions)).addObjects(from: mentions)
@ -160,7 +163,7 @@ public extension Toot {
func update(liked: Bool, mastodonUser: MastodonUser) {
if liked {
if !(self.favouritedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(Toot.favouritedBy)).addObjects(from: [mastodonUser])
self.mutableSetValue(forKey: #keyPath(Toot.favouritedBy)).add(mastodonUser)
}
} else {
if (self.favouritedBy ?? Set()).contains(mastodonUser) {
@ -171,7 +174,7 @@ public extension Toot {
func update(reblogged: Bool, mastodonUser: MastodonUser) {
if reblogged {
if !(self.rebloggedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(Toot.rebloggedBy)).addObjects(from: [mastodonUser])
self.mutableSetValue(forKey: #keyPath(Toot.rebloggedBy)).add(mastodonUser)
}
} else {
if (self.rebloggedBy ?? Set()).contains(mastodonUser) {
@ -183,7 +186,7 @@ public extension Toot {
func update(muted: Bool, mastodonUser: MastodonUser) {
if muted {
if !(self.mutedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(Toot.mutedBy)).addObjects(from: [mastodonUser])
self.mutableSetValue(forKey: #keyPath(Toot.mutedBy)).add(mastodonUser)
}
} else {
if (self.mutedBy ?? Set()).contains(mastodonUser) {
@ -195,7 +198,7 @@ public extension Toot {
func update(bookmarked: Bool, mastodonUser: MastodonUser) {
if bookmarked {
if !(self.bookmarkedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(Toot.bookmarkedBy)).addObjects(from: [mastodonUser])
self.mutableSetValue(forKey: #keyPath(Toot.bookmarkedBy)).add(mastodonUser)
}
} else {
if (self.bookmarkedBy ?? Set()).contains(mastodonUser) {

View File

@ -1,39 +1,20 @@
{
"common": {
"errors": {
"item": {
"username": "username",
"email": "email",
"password": "password",
"agreement": "agreement",
"locale": "locale",
"reason": "reason"
},
"itemDetail": {
"email_invalid": "This is not a valid e-mail address",
"username_invalid": "Username must only contain alphanumeric characters and underscores",
"password_too_shrot": "password is too short (must be at least 8 characters)",
"username_too_long": "username is too long (can't be longer than 30 characters)"
},
"ERR_BLOCKED": "contains a disallowed e-mail provider",
"ERR_UNREACHABLE": "does not seem to exist",
"ERR_TAKEN": "is already in use",
"ERR_RESERVED": "is a reserved keyword",
"ERR_ACCEPTED": "must be accepted",
"ERR_BLANK": "is required",
"ERR_INVALID": "is invalid",
"ERR_TOO_LONG": "is too long",
"ERR_TOO_SHORT": "is too short",
"ERR_INCLUSION": "is not a supported value"
},
"alerts": {
"common": {
"please_try_again": "Please try again.",
"please_try_again_later": "Please try again later."
},
"sign_up_failure": {
"title": "Sign Up Failure"
},
"server_error": {
"title": "Server Error"
},
"vote_failure": {
"title": "Vote Failure",
"poll_expired": "The poll has expired"
}
},
"controls": {
"actions": {
@ -55,10 +36,23 @@
"open_in_safari": "Open in Safari"
},
"status": {
"user_boosted": "%s boosted",
"user_reblogged": "%s reblogged",
"show_post": "Show Post",
"status_content_warning": "content warning",
"media_content_warning": "Tap to reveal that may be sensitive"
"media_content_warning": "Tap to reveal that may be sensitive",
"poll": {
"vote": "Vote",
"vote_count": {
"single": "%d vote",
"multiple": "%d votes",
},
"voter_count": {
"single": "%d voter",
"multiple": "%d voters",
},
"time_left": "%s left",
"closed": "Closed"
}
},
"timeline": {
"load_more": "Load More"
@ -91,6 +85,10 @@
},
"input": {
"placeholder": "Find a server or join your own..."
},
"empty_state": {
"finding_servers": "Finding available servers...",
"bad_network": "Something went wrong while loading data. Check your internet connection."
}
},
"register": {
@ -108,15 +106,40 @@
},
"password": {
"placeholder": "password",
"prompt": "Your password needs at least:",
"prompt_eight_characters": "Eight characters"
"hint": "Your password needs at least eight characters"
},
"invite": {
"registration_user_invite_request": "Why do you want to join?"
"registration_user_invite_request": "Why do you want to join?"
}
},
"success": "Success",
"check_email": "Regsiter request sent. Please check your email."
"error": {
"item": {
"username": "Username",
"email": "Email",
"password": "Password",
"agreement": "Agreement",
"locale": "Locale",
"reason": "Reason"
},
"reason": {
"blocked": "%s contains a disallowed e-mail provider",
"unreachable": "%s does not seem to exist",
"taken": "%s is already in use",
"reserved": "%s is a reserved keyword",
"accepted": "%s must be accepted",
"blank": "%s is required",
"invalid": "%s is invalid",
"too_long": "%s is too long",
"too_short": "%s is too short",
"inclusion": "%s is not a supported value"
},
"special": {
"username_invalid": "Username must only contain alphanumeric characters and underscores",
"username_too_long": "Username is too long (can't be longer than 30 characters)",
"email_invalid": "This is not a valid e-mail address",
"password_too_short": "Password is too short (must be at least 8 characters)"
}
}
},
"server_rules": {
"title": "Some ground rules.",
@ -152,4 +175,4 @@
"title": "Public"
}
}
}
}

View File

@ -22,6 +22,11 @@
2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */; };
2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A8B25C295CC009AA50C /* StatusView.swift */; };
2D152A9225C2980C009AA50C /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A9125C2980C009AA50C /* UIFont.swift */; };
2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */; };
2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7F25F5F45E00143C56 /* UIImage.swift */; };
2D206B8625F5FB0900143C56 /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8525F5FB0900143C56 /* Double.swift */; };
2D206B8C25F6015000143C56 /* AudioPlaybackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */; };
2D206B9225F60EA700143C56 /* UIControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B9125F60EA700143C56 /* UIControl.swift */; };
2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */; };
2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAB925CB9B0500C9ED86 /* UIView.swift */; };
2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */; };
@ -44,7 +49,6 @@
2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8E25C8228A004A627A /* UIButton.swift */; };
2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */; };
2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */; };
2D46976425C2A71500CF4AA9 /* UIIamge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D46976325C2A71500CF4AA9 /* UIIamge.swift */; };
2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D59819A25E4A581000FB903 /* MastodonConfirmEmailViewController.swift */; };
2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5981A025E4A593000FB903 /* MastodonConfirmEmailViewModel.swift */; };
2D5981BA25E4D7F8000FB903 /* ThirdPartyMailer in Frameworks */ = {isa = PBXBuildFile; productRef = 2D5981B925E4D7F8000FB903 /* ThirdPartyMailer */; };
@ -55,7 +59,8 @@
2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */; };
2D61335E25C1894B00CAE157 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335D25C1894B00CAE157 /* APIService.swift */; };
2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */; };
2D650FAB25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReason.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D650FAA25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReason.swift */; };
2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */; };
2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */; };
2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */; };
2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Toot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */; };
2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */; };
@ -72,17 +77,26 @@
2D927F0E25C7E9C9004F19B8 /* History.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0D25C7E9C9004F19B8 /* History.swift */; };
2D927F1425C7EDD9004F19B8 /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F1325C7EDD9004F19B8 /* Emoji.swift */; };
2D939AB525EDD8A90076FA61 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D939AB425EDD8A90076FA61 /* String.swift */; };
2D939AC825EE14620076FA61 /* CropViewController in Frameworks */ = {isa = PBXBuildFile; productRef = 2D939AC725EE14620076FA61 /* CropViewController */; };
2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D939AE725EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift */; };
2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA6054625F716A2006356F9 /* PlaybackState.swift */; };
2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA6055025F74407006356F9 /* AudioContainerViewModel.swift */; };
2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */; };
2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */; };
2DA7D05725CA693F00804E11 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D05625CA693F00804E11 /* Application.swift */; };
2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF123A625C3B0210020F248 /* ActiveLabel.swift */; };
2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75B9A25D0E27500694EC8 /* StatusProviderFacade.swift */; };
2DF75BA125D0E29D00694EC8 /* StatusProvider+TimelinePostTableViewCellDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BA025D0E29D00694EC8 /* StatusProvider+TimelinePostTableViewCellDelegate.swift */; };
2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BA025D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift */; };
2DF75BA725D10E1000694EC8 /* APIService+Favorite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */; };
2DF75BB925D1474100694EC8 /* ManagedObjectObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BB825D1474100694EC8 /* ManagedObjectObserver.swift */; };
2DF75BC725D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */; };
45B49097460EDE530AD5AA72 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */; };
5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 5D526FE125BE9AC400460CB9 /* MastodonSDK */; };
5DF1054125F886D400D6C0D4 /* ViedeoPlaybackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */; };
5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */; };
5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */; };
5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1057825F88A1D00D6C0D4 /* PlayerContainerView.swift */; };
5DF1057F25F88A4100D6C0D4 /* TouchBlockingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1057E25F88A4100D6C0D4 /* TouchBlockingView.swift */; };
5DF1058525F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */; };
5E0DEC05797A7E6933788DDB /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */; };
5E44BF88AD33646E64727BCF /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */; };
DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140A025C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift */; };
@ -94,6 +108,13 @@
DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */; };
DB118A8225E4B6E600FAB162 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */; };
DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */; };
DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1D186B25EF5BA7003F1F23 /* PollTableView.swift */; };
DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */; };
DB1E347825F519300079D7DF /* PickServerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1E347725F519300079D7DF /* PickServerItem.swift */; };
DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD43525F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift */; };
DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */; };
DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD44F25F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift */; };
DB1FD45A25F27898004CFCFC /* CategoryPickerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */; };
DB2B3ABC25E37E15007045F9 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = DB2B3ABE25E37E15007045F9 /* InfoPlist.strings */; };
DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2B3AE825E38850007045F9 /* UIViewPreview.swift */; };
DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */; };
@ -106,6 +127,12 @@
DB427DE225BAA00100D1B89D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = DB427DE025BAA00100D1B89D /* LaunchScreen.storyboard */; };
DB427DED25BAA00100D1B89D /* MastodonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DEC25BAA00100D1B89D /* MastodonTests.swift */; };
DB427DF825BAA00100D1B89D /* MastodonUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DF725BAA00100D1B89D /* MastodonUITests.swift */; };
DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB44384E25E8C1FA008912A2 /* CALayer.swift */; };
DB4481AD25EE155900BEFB67 /* Poll.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4481AC25EE155900BEFB67 /* Poll.swift */; };
DB4481B325EE16D000BEFB67 /* PollOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4481B225EE16D000BEFB67 /* PollOption.swift */; };
DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4481B825EE289600BEFB67 /* UITableView.swift */; };
DB4481C625EE2ADA00BEFB67 /* PollSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4481C525EE2ADA00BEFB67 /* PollSection.swift */; };
DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4481CB25EE2AFE00BEFB67 /* PollItem.swift */; };
DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */; };
DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */; };
DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */; };
@ -115,14 +142,22 @@
DB45FAF925CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */; };
DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */; };
DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */; };
DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */; };
DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */; };
DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */; };
DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */; };
DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = DB5086B725CC0D6400C2C187 /* Kingfisher */; };
DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */; };
DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */; };
DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */; };
DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */; };
DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F11725EFA35B001F1DAB /* StripProgressView.swift */; };
DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */; };
DB68A04A25E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */; };
DB68A05D25E9055900CFDF14 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = DB68A05C25E9055900CFDF14 /* Settings.bundle */; };
DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A06225E905E000CFDF14 /* UIApplication.swift */; };
DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */; };
DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */; };
DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */; };
DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */; };
DB89B9F725C10FD0008580ED /* CoreDataStack.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */; };
@ -147,6 +182,8 @@
DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF54F25C13703002E6C99 /* MainTabBarController.swift */; };
DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF55C25C138B7002E6C99 /* UIViewController.swift */; };
DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */; };
DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9282B125F3222800823B15 /* PickServerEmptyStateView.swift */; };
DB92CF7225E7BB98002C1017 /* PollOptionTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB92CF7125E7BB98002C1017 /* PollOptionTableViewCell.swift */; };
DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98336A25C9420100AD9700 /* APIService+App.swift */; };
DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337025C9443200AD9700 /* APIService+Authentication.swift */; };
DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337E25C9452D00AD9700 /* APIService+APIError.swift */; };
@ -160,6 +197,8 @@
DB9D6C2425E502C60051B173 /* MosaicImageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */; };
DB9D6C2E25E504AC0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2D25E504AC0051B173 /* Attachment.swift */; };
DB9D6C3825E508BE0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C3725E508BE0051B173 /* Attachment.swift */; };
DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */; };
DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */; };
DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */; };
DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */; };
DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; };
@ -234,6 +273,11 @@
2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+PublicTimeline.swift"; sourceTree = "<group>"; };
2D152A8B25C295CC009AA50C /* StatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusView.swift; sourceTree = "<group>"; };
2D152A9125C2980C009AA50C /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = "<group>"; };
2D206B7125F5D27F00143C56 /* AudioContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContainerView.swift; sourceTree = "<group>"; };
2D206B7F25F5F45E00143C56 /* UIImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.swift; sourceTree = "<group>"; };
2D206B8525F5FB0900143C56 /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.swift; sourceTree = "<group>"; };
2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlaybackService.swift; sourceTree = "<group>"; };
2D206B9125F60EA700143C56 /* UIControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIControl.swift; sourceTree = "<group>"; };
2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMiddleLoaderTableViewCell.swift; sourceTree = "<group>"; };
2D32EAB925CB9B0500C9ED86 /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = "<group>"; };
2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+LoadMiddleState.swift"; sourceTree = "<group>"; };
@ -255,7 +299,6 @@
2D42FF8E25C8228A004A627A /* UIButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIButton.swift; sourceTree = "<group>"; };
2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+State.swift"; sourceTree = "<group>"; };
2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSLayoutConstraint.swift; sourceTree = "<group>"; };
2D46976325C2A71500CF4AA9 /* UIIamge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIIamge.swift; sourceTree = "<group>"; };
2D59819A25E4A581000FB903 /* MastodonConfirmEmailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonConfirmEmailViewController.swift; sourceTree = "<group>"; };
2D5981A025E4A593000FB903 /* MastodonConfirmEmailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonConfirmEmailViewModel.swift; sourceTree = "<group>"; };
2D5A3D0225CF8742002347D6 /* ControlContainableScrollViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlContainableScrollViews.swift; sourceTree = "<group>"; };
@ -264,7 +307,8 @@
2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewController+DebugAction.swift"; sourceTree = "<group>"; };
2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+Timeline.swift"; sourceTree = "<group>"; };
2D61335D25C1894B00CAE157 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = "<group>"; };
2D650FAA25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReason.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entidy+ErrorDetailReason.swift"; sourceTree = "<group>"; };
2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error+Detail.swift"; sourceTree = "<group>"; };
2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningOverlayView.swift; sourceTree = "<group>"; };
2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = "<group>"; };
2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Toot.swift"; sourceTree = "<group>"; };
2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewController.swift; sourceTree = "<group>"; };
@ -281,13 +325,16 @@
2D927F0D25C7E9C9004F19B8 /* History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = History.swift; sourceTree = "<group>"; };
2D927F1325C7EDD9004F19B8 /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = "<group>"; };
2D939AB425EDD8A90076FA61 /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = "<group>"; };
2D939AE725EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonRegisterViewController+Avatar.swift"; sourceTree = "<group>"; };
2DA6054625F716A2006356F9 /* PlaybackState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackState.swift; sourceTree = "<group>"; };
2DA6055025F74407006356F9 /* AudioContainerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContainerViewModel.swift; sourceTree = "<group>"; };
2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLoaderTableViewCell.swift; sourceTree = "<group>"; };
2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBottomLoaderTableViewCell.swift; sourceTree = "<group>"; };
2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = "<group>"; };
2DA7D05625CA693F00804E11 /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = "<group>"; };
2DF123A625C3B0210020F248 /* ActiveLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveLabel.swift; sourceTree = "<group>"; };
2DF75B9A25D0E27500694EC8 /* StatusProviderFacade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusProviderFacade.swift; sourceTree = "<group>"; };
2DF75BA025D0E29D00694EC8 /* StatusProvider+TimelinePostTableViewCellDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+TimelinePostTableViewCellDelegate.swift"; sourceTree = "<group>"; };
2DF75BA025D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+StatusTableViewCellDelegate.swift"; sourceTree = "<group>"; };
2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Favorite.swift"; sourceTree = "<group>"; };
2DF75BB825D1474100694EC8 /* ManagedObjectObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedObjectObserver.swift; sourceTree = "<group>"; };
2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedObjectContextObjectsDidChange.swift; sourceTree = "<group>"; };
@ -295,6 +342,12 @@
3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
459EA4F43058CAB47719E963 /* Pods-Mastodon-MastodonUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.debug.xcconfig"; sourceTree = "<group>"; };
5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViedeoPlaybackService.swift; sourceTree = "<group>"; };
5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModel.swift; sourceTree = "<group>"; };
5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVPlayer.swift; sourceTree = "<group>"; };
5DF1057825F88A1D00D6C0D4 /* PlayerContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerContainerView.swift; sourceTree = "<group>"; };
5DF1057E25F88A4100D6C0D4 /* TouchBlockingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TouchBlockingView.swift; sourceTree = "<group>"; };
5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NeedsDependency+AVPlayerViewControllerDelegate.swift"; sourceTree = "<group>"; };
75E3471C898DDD9631729B6E /* Pods-Mastodon.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.release.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.release.xcconfig"; sourceTree = "<group>"; };
9780A4C98FFC65B32B50D1C0 /* Pods-MastodonTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.release.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.release.xcconfig"; sourceTree = "<group>"; };
A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@ -308,6 +361,14 @@
DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Instance.swift"; sourceTree = "<group>"; };
DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightDimmableButton.swift; sourceTree = "<group>"; };
DB1D186B25EF5BA7003F1F23 /* PollTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollTableView.swift; sourceTree = "<group>"; };
DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CategoryPickerSection.swift; sourceTree = "<group>"; };
DB1E347725F519300079D7DF /* PickServerItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PickServerItem.swift; sourceTree = "<group>"; };
DB1FD43525F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonPickServerViewModel+LoadIndexedServerState.swift"; sourceTree = "<group>"; };
DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerSection.swift; sourceTree = "<group>"; };
DB1FD44F25F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonPickServerViewModel+Diffable.swift"; sourceTree = "<group>"; };
DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPickerItem.swift; sourceTree = "<group>"; };
DB1FD45F25F278AF004CFCFC /* CategoryPickerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPickerSection.swift; sourceTree = "<group>"; };
DB2B3ABD25E37E15007045F9 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
DB2B3AE825E38850007045F9 /* UIViewPreview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewPreview.swift; sourceTree = "<group>"; };
DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthenticationViewModel.swift; sourceTree = "<group>"; };
@ -326,6 +387,12 @@
DB427DF325BAA00100D1B89D /* MastodonUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MastodonUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
DB427DF725BAA00100D1B89D /* MastodonUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonUITests.swift; sourceTree = "<group>"; };
DB427DF925BAA00100D1B89D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
DB44384E25E8C1FA008912A2 /* CALayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CALayer.swift; sourceTree = "<group>"; };
DB4481AC25EE155900BEFB67 /* Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Poll.swift; sourceTree = "<group>"; };
DB4481B225EE16D000BEFB67 /* PollOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOption.swift; sourceTree = "<group>"; };
DB4481B825EE289600BEFB67 /* UITableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UITableView.swift; sourceTree = "<group>"; };
DB4481C525EE2ADA00BEFB67 /* PollSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollSection.swift; sourceTree = "<group>"; };
DB4481CB25EE2AFE00BEFB67 /* PollItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollItem.swift; sourceTree = "<group>"; };
DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeyboardResponderService.swift; sourceTree = "<group>"; };
DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIAlertController.swift; sourceTree = "<group>"; };
DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIBarButtonItem.swift; sourceTree = "<group>"; };
@ -335,13 +402,21 @@
DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+MastodonAuthentication.swift"; sourceTree = "<group>"; };
DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationService.swift; sourceTree = "<group>"; };
DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+HomeTimeline.swift"; sourceTree = "<group>"; };
DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSManagedObjectContext.swift; sourceTree = "<group>"; };
DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PlayerContainerView+MediaTypeIndicotorView.swift"; sourceTree = "<group>"; };
DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarBarButtonItem.swift; sourceTree = "<group>"; };
DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarConfigurableView.swift; sourceTree = "<group>"; };
DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SplashPreference.swift; sourceTree = "<group>"; };
DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+UITableViewDelegate.swift"; sourceTree = "<group>"; };
DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewCellHeightCacheableContainer.swift; sourceTree = "<group>"; };
DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Poll.swift"; sourceTree = "<group>"; };
DB59F11725EFA35B001F1DAB /* StripProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripProgressView.swift; sourceTree = "<group>"; };
DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSKeyValueObservation.swift; sourceTree = "<group>"; };
DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DarkContentStatusBarStyleNavigationController.swift; sourceTree = "<group>"; };
DB68A05C25E9055900CFDF14 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = "<group>"; };
DB68A06225E905E000CFDF14 /* UIApplication.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = "<group>"; };
DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = "<group>"; };
DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStackContainerButton.swift; sourceTree = "<group>"; };
DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewController.swift; sourceTree = "<group>"; };
DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewModel.swift; sourceTree = "<group>"; };
DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = CoreDataStack.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@ -368,6 +443,8 @@
DB8AF54F25C13703002E6C99 /* MainTabBarController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainTabBarController.swift; sourceTree = "<group>"; };
DB8AF55C25C138B7002E6C99 /* UIViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewController.swift; sourceTree = "<group>"; };
DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineIndex.swift; sourceTree = "<group>"; };
DB9282B125F3222800823B15 /* PickServerEmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerEmptyStateView.swift; sourceTree = "<group>"; };
DB92CF7125E7BB98002C1017 /* PollOptionTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionTableViewCell.swift; sourceTree = "<group>"; };
DB98336A25C9420100AD9700 /* APIService+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+App.swift"; sourceTree = "<group>"; };
DB98337025C9443200AD9700 /* APIService+Authentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Authentication.swift"; sourceTree = "<group>"; };
DB98337E25C9452D00AD9700 /* APIService+APIError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+APIError.swift"; sourceTree = "<group>"; };
@ -381,6 +458,8 @@
DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MosaicImageViewModel.swift; sourceTree = "<group>"; };
DB9D6C2D25E504AC0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = "<group>"; };
DB9D6C3725E508BE0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = "<group>"; };
DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = "<group>"; };
DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Reblog.swift"; sourceTree = "<group>"; };
DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIInterpolatingMotionEffect.swift; sourceTree = "<group>"; };
DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = "<group>"; };
DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = "<group>"; };
@ -399,12 +478,12 @@
DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */,
DB89BA0325C10FD0008580ED /* CoreDataStack.framework in Frameworks */,
2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */,
2D939AC825EE14620076FA61 /* CropViewController in Frameworks */,
5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */,
DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */,
2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */,
DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */,
2D5981BA25E4D7F8000FB903 /* ThirdPartyMailer in Frameworks */,
45B49097460EDE530AD5AA72 /* Pods_Mastodon.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -455,11 +534,14 @@
0FAA102525E1125D0017CCDE /* PickServer */ = {
isa = PBXGroup;
children = (
0FB3D31825E525DE00AAD544 /* CollectionViewCell */,
0FB3D30D25E525C000AAD544 /* View */,
0FB3D31825E525DE00AAD544 /* CollectionViewCell */,
0FB3D2FC25E4CB4B00AAD544 /* TableViewCell */,
DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */,
0FAA102625E1126A0017CCDE /* MastodonPickServerViewController.swift */,
0FB3D2F625E4C24D00AAD544 /* MastodonPickServerViewModel.swift */,
DB1FD44F25F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift */,
DB1FD43525F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift */,
);
path = PickServer;
sourceTree = "<group>";
@ -479,6 +561,7 @@
isa = PBXGroup;
children = (
0FB3D30E25E525CD00AAD544 /* PickServerCategoryView.swift */,
DB9282B125F3222800823B15 /* PickServerEmptyStateView.swift */,
);
path = View;
sourceTree = "<group>";
@ -508,6 +591,7 @@
isa = PBXGroup;
children = (
2D152A8B25C295CC009AA50C /* StatusView.swift */,
2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */,
);
path = Content;
sourceTree = "<group>";
@ -542,7 +626,8 @@
children = (
2D38F1FD25CD481700561493 /* StatusProvider.swift */,
2DF75B9A25D0E27500694EC8 /* StatusProviderFacade.swift */,
2DF75BA025D0E29D00694EC8 /* StatusProvider+TimelinePostTableViewCellDelegate.swift */,
2DF75BA025D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift */,
DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */,
);
path = StatusProvider;
sourceTree = "<group>";
@ -590,6 +675,9 @@
DB45FB0425CA87B4005A8AC7 /* APIService */,
DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */,
DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */,
2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */,
2DA6054625F716A2006356F9 /* PlaybackState.swift */,
5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */,
);
path = Service;
sourceTree = "<group>";
@ -608,9 +696,11 @@
2D38F1FC25CD47D900561493 /* StatusProvider */,
DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */,
2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */,
2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */,
DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */,
2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */,
2D38F20725CD491300561493 /* DisposeBagCollectable.swift */,
2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */,
5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */,
);
path = Protocol;
sourceTree = "<group>";
@ -631,8 +721,8 @@
2D76319C25C151DE00929FB9 /* Diffiable */ = {
isa = PBXGroup;
children = (
2D7631B125C159E700929FB9 /* Item */,
2D76319D25C151F600929FB9 /* Section */,
2D7631B125C159E700929FB9 /* Item */,
);
path = Diffiable;
sourceTree = "<group>";
@ -641,6 +731,9 @@
isa = PBXGroup;
children = (
2D76319E25C1521200929FB9 /* StatusSection.swift */,
DB4481C525EE2ADA00BEFB67 /* PollSection.swift */,
DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */,
DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */,
);
path = Section;
sourceTree = "<group>";
@ -661,7 +754,9 @@
2D42FF8325C82245004A627A /* Button */,
2D42FF7C25C82207004A627A /* ToolBar */,
DB9D6C1325E4F97A0051B173 /* Container */,
DBA9B90325F1D4420012E7B6 /* Control */,
2D152A8A25C295B8009AA50C /* Content */,
DB1D187125EF5BBD003F1F23 /* TableView */,
2D7631A625C1533800929FB9 /* TableviewCell */,
);
path = View;
@ -674,6 +769,7 @@
2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */,
2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */,
2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */,
DB92CF7125E7BB98002C1017 /* PollOptionTableViewCell.swift */,
);
path = TableviewCell;
sourceTree = "<group>";
@ -682,6 +778,9 @@
isa = PBXGroup;
children = (
2D7631B225C159F700929FB9 /* Item.swift */,
DB4481CB25EE2AFE00BEFB67 /* PollItem.swift */,
DB1E347725F519300079D7DF /* PickServerItem.swift */,
DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */,
);
path = Item;
sourceTree = "<group>";
@ -740,6 +839,14 @@
path = CoreDataStack;
sourceTree = "<group>";
};
DB1D187125EF5BBD003F1F23 /* TableView */ = {
isa = PBXGroup;
children = (
DB1D186B25EF5BA7003F1F23 /* PollTableView.swift */,
);
path = TableView;
sourceTree = "<group>";
};
DB3D0FF725BAA68500EAA174 /* Supporting Files */ = {
isa = PBXGroup;
children = (
@ -839,15 +946,17 @@
DB45FB0925CA87BC005A8AC7 /* CoreData */,
2D61335625C1887F00CAE157 /* Persist */,
2D61335D25C1894B00CAE157 /* APIService.swift */,
DB98337E25C9452D00AD9700 /* APIService+APIError.swift */,
DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */,
2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */,
DB98337E25C9452D00AD9700 /* APIService+APIError.swift */,
DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */,
DB98336A25C9420100AD9700 /* APIService+App.swift */,
DB98337025C9443200AD9700 /* APIService+Authentication.swift */,
DB98339B25C96DE600AD9700 /* APIService+Account.swift */,
2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */,
DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */,
DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */,
DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */,
);
path = APIService;
sourceTree = "<group>";
@ -887,6 +996,15 @@
path = NavigationController;
sourceTree = "<group>";
};
DB6C8C0525F0921200AAA452 /* MastodonSDK */ = {
isa = PBXGroup;
children = (
DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */,
2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */,
);
path = MastodonSDK;
sourceTree = "<group>";
};
DB72602125E36A2500235243 /* ServerRules */ = {
isa = PBXGroup;
children = (
@ -944,6 +1062,8 @@
DB45FAEC25CA7A9A005A8AC7 /* MastodonAuthentication.swift */,
2DA7D05625CA693F00804E11 /* Application.swift */,
DB9D6C2D25E504AC0051B173 /* Attachment.swift */,
DB4481AC25EE155900BEFB67 /* Poll.swift */,
DB4481B225EE16D000BEFB67 /* PollOption.swift */,
);
path = Entity;
sourceTree = "<group>";
@ -1003,7 +1123,12 @@
isa = PBXGroup;
children = (
DB084B5125CBC56300F898ED /* CoreDataStack */,
DB9E0D6425EDFF5600CFDD76 /* MastodonSDK */,
DB6C8C0525F0921200AAA452 /* MastodonSDK */,
DB44384E25E8C1FA008912A2 /* CALayer.swift */,
DB0140CE25C42AEE00F9F3CF /* OSLog.swift */,
2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */,
DB8AF55C25C138B7002E6C99 /* UIViewController.swift */,
2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */,
2DF123A625C3B0210020F248 /* ActiveLabel.swift */,
2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */,
DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */,
@ -1013,7 +1138,15 @@
DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */,
2D42FF8E25C8228A004A627A /* UIButton.swift */,
DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */,
DB4481B825EE289600BEFB67 /* UITableView.swift */,
2D32EAB925CB9B0500C9ED86 /* UIView.swift */,
0FAA101B25E10E760017CCDE /* UIFont.swift */,
2D939AB425EDD8A90076FA61 /* String.swift */,
2D206B7F25F5F45E00143C56 /* UIImage.swift */,
2D206B8525F5FB0900143C56 /* Double.swift */,
2D206B9125F60EA700143C56 /* UIControl.swift */,
5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */,
DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */,
2D46976325C2A71500CF4AA9 /* UIIamge.swift */,
2D32EAB925CB9B0500C9ED86 /* UIView.swift */,
DB8AF55C25C138B7002E6C99 /* UIViewController.swift */,
@ -1028,6 +1161,7 @@
children = (
CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */,
2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */,
DB1FD45F25F278AF004CFCFC /* CategoryPickerSection.swift */,
);
name = "Recovered References";
sourceTree = "<group>";
@ -1069,6 +1203,10 @@
isa = PBXGroup;
children = (
DB9D6C0D25E4F9780051B173 /* MosaicImageViewContainer.swift */,
2D206B7125F5D27F00143C56 /* AudioContainerView.swift */,
5DF1057825F88A1D00D6C0D4 /* PlayerContainerView.swift */,
DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */,
5DF1057E25F88A4100D6C0D4 /* TouchBlockingView.swift */,
);
path = Container;
sourceTree = "<group>";
@ -1077,16 +1215,19 @@
isa = PBXGroup;
children = (
DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */,
2DA6055025F74407006356F9 /* AudioContainerViewModel.swift */,
5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */,
);
path = ViewModel;
sourceTree = "<group>";
};
DB9E0D6425EDFF5600CFDD76 /* MastodonSDK */ = {
DBA9B90325F1D4420012E7B6 /* Control */ = {
isa = PBXGroup;
children = (
2D650FAA25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReason.swift */,
DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */,
DB59F11725EFA35B001F1DAB /* StripProgressView.swift */,
);
path = MastodonSDK;
path = Control;
sourceTree = "<group>";
};
DB9E0D6925EDFFE500CFDD76 /* Helper */ = {
@ -1109,6 +1250,7 @@
isa = PBXGroup;
children = (
DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */,
2D939AE725EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift */,
DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */,
);
path = Register;
@ -1154,6 +1296,7 @@
DB0140BC25C40D7500F9F3CF /* CommonOSLog */,
DB5086B725CC0D6400C2C187 /* Kingfisher */,
2D5981B925E4D7F8000FB903 /* ThirdPartyMailer */,
2D939AC725EE14620076FA61 /* CropViewController */,
);
productName = Mastodon;
productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */;
@ -1282,6 +1425,7 @@
DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */,
DB5086B625CC0D6400C2C187 /* XCRemoteSwiftPackageReference "Kingfisher" */,
2D5981B825E4D7F8000FB903 /* XCRemoteSwiftPackageReference "ThirdPartyMailer" */,
2D939AC625EE14620076FA61 /* XCRemoteSwiftPackageReference "TOCropViewController" */,
);
productRefGroup = DB427DD325BAA00100D1B89D /* Products */;
projectDirPath = "";
@ -1467,17 +1611,23 @@
buildActionMask = 2147483647;
files = (
DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */,
5DF1057F25F88A4100D6C0D4 /* TouchBlockingView.swift in Sources */,
0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */,
2D206B8C25F6015000143C56 /* AudioPlaybackService.swift in Sources */,
2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */,
DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */,
2D7631B325C159F700929FB9 /* Item.swift in Sources */,
5DF1054125F886D400D6C0D4 /* ViedeoPlaybackService.swift in Sources */,
0FB3D2F725E4C24D00AAD544 /* MastodonPickServerViewModel.swift in Sources */,
2D61335E25C1894B00CAE157 /* APIService.swift in Sources */,
2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */,
0FB3D33225E5F50E00AAD544 /* PickServerSearchCell.swift in Sources */,
2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */,
DB1E347825F519300079D7DF /* PickServerItem.swift in Sources */,
DB1FD45A25F27898004CFCFC /* CategoryPickerItem.swift in Sources */,
0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */,
DB8AF53025C13561002E6C99 /* AppContext.swift in Sources */,
DB92CF7225E7BB98002C1017 /* PollOptionTableViewCell.swift in Sources */,
DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */,
2D364F7225E66D7500204FDC /* MastodonResendEmailViewController.swift in Sources */,
2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */,
@ -1489,17 +1639,21 @@
DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */,
2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */,
0FAA102725E1126A0017CCDE /* MastodonPickServerViewController.swift in Sources */,
DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */,
DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */,
2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */,
DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */,
DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */,
2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Toot.swift in Sources */,
DB4481C625EE2ADA00BEFB67 /* PollSection.swift in Sources */,
2D939AB525EDD8A90076FA61 /* String.swift in Sources */,
DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */,
0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */,
2D38F1D525CD465300561493 /* HomeTimelineViewController.swift in Sources */,
DB98338825C945ED00AD9700 /* Assets.swift in Sources */,
2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */,
DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */,
2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */,
DB9D6C2425E502C60051B173 /* MosaicImageViewModel.swift in Sources */,
2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */,
2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */,
@ -1507,28 +1661,44 @@
DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */,
DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */,
2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */,
DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */,
DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */,
DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */,
2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */,
DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */,
2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */,
5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */,
2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */,
2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */,
2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */,
0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */,
2D38F1DF25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift in Sources */,
2D206B9225F60EA700143C56 /* UIControl.swift in Sources */,
2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */,
2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */,
2D46976425C2A71500CF4AA9 /* UIIamge.swift in Sources */,
DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */,
2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */,
2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */,
DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */,
DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */,
DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */,
2DF75BA125D0E29D00694EC8 /* StatusProvider+TimelinePostTableViewCellDelegate.swift in Sources */,
2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */,
5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */,
2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */,
DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */,
DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */,
2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */,
DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */,
DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */,
2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */,
DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */,
2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */,
DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */,
DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */,
2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */,
DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */,
DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */,
DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */,
0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */,
DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */,
DB68A04A25E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift in Sources */,
@ -1539,15 +1709,21 @@
DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */,
2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */,
2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */,
DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */,
DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */,
DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */,
2D206B8625F5FB0900143C56 /* Double.swift in Sources */,
2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */,
2D650FAB25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReason.swift in Sources */,
2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */,
DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */,
DB084B5725CBC56C00F898ED /* Toot.swift in Sources */,
DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */,
DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */,
DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */,
DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */,
DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */,
DB9D6C0E25E4F9780051B173 /* MosaicImageViewContainer.swift in Sources */,
DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */,
DB98338725C945ED00AD9700 /* Strings.swift in Sources */,
DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */,
DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */,
@ -1555,9 +1731,11 @@
DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */,
2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */,
DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */,
5DF1058525F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift in Sources */,
DB45FAF925CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift in Sources */,
2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */,
2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */,
2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */,
2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */,
DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */,
2D42FF6B25C817D2004A627A /* TootContent.swift in Sources */,
@ -1573,12 +1751,15 @@
DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */,
DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */,
2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */,
DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */,
2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */,
DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */,
0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */,
2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */,
0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */,
5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */,
DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */,
DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -1612,8 +1793,10 @@
DB89BA3725C1145C008580ED /* CoreData.xcdatamodeld in Sources */,
DB8AF52525C131D1002E6C99 /* MastodonUser.swift in Sources */,
DB89BA1B25C1107F008580ED /* Collection.swift in Sources */,
DB4481AD25EE155900BEFB67 /* Poll.swift in Sources */,
DB89BA2725C110B4008580ED /* Toot.swift in Sources */,
2D152A9225C2980C009AA50C /* UIFont.swift in Sources */,
DB4481B325EE16D000BEFB67 /* PollOption.swift in Sources */,
DB89BA4425C1165F008580ED /* Managed.swift in Sources */,
DB89BA4325C1165F008580ED /* NetworkUpdatable.swift in Sources */,
DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */,
@ -2131,6 +2314,14 @@
minimumVersion = 3.1.0;
};
};
2D939AC625EE14620076FA61 /* XCRemoteSwiftPackageReference "TOCropViewController" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/TimOliver/TOCropViewController.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.6.0;
};
};
DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/MainasuK/CommonOSLog";
@ -2173,6 +2364,11 @@
package = 2D61336725C18A4F00CAE157 /* XCRemoteSwiftPackageReference "AlamofireNetworkActivityIndicator" */;
productName = AlamofireNetworkActivityIndicator;
};
2D939AC725EE14620076FA61 /* CropViewController */ = {
isa = XCSwiftPackageProductDependency;
package = 2D939AC625EE14620076FA61 /* XCRemoteSwiftPackageReference "TOCropViewController" */;
productName = CropViewController;
};
5D526FE125BE9AC400460CB9 /* MastodonSDK */ = {
isa = XCSwiftPackageProductDependency;
productName = MastodonSDK;

View File

@ -17,36 +17,15 @@
<key>Mastodon - Release.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>3</integer>
<integer>2</integer>
</dict>
<key>Mastodon.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
<integer>7</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict>
<key>DB427DD125BAA00100D1B89D</key>
<dict>
<key>primary</key>
<true/>
</dict>
<key>DB427DE725BAA00100D1B89D</key>
<dict>
<key>primary</key>
<true/>
</dict>
<key>DB427DF225BAA00100D1B89D</key>
<dict>
<key>primary</key>
<true/>
</dict>
<key>DB89B9F525C10FD0008580ED</key>
<dict>
<key>primary</key>
<true/>
</dict>
</dict>
<dict/>
</dict>
</plist>

View File

@ -51,8 +51,8 @@
"repositoryURL": "https://github.com/onevcat/Kingfisher.git",
"state": {
"branch": null,
"revision": "daebf8ddf974164d1b9a050c8231e263f3106b09",
"version": "6.1.0"
"revision": "81dd1ce8401137637663046c7314e7c885bcc56d",
"version": "6.1.1"
}
},
{
@ -90,6 +90,15 @@
"revision": "923c60ee7588da47db8cfc4e0f5b96e5e605ef84",
"version": "1.7.1"
}
},
{
"package": "TOCropViewController",
"repositoryURL": "https://github.com/TimOliver/TOCropViewController.git",
"state": {
"branch": null,
"revision": "dad97167bf1be16aeecd109130900995dd01c515",
"version": "2.6.0"
}
}
]
},

View File

@ -82,7 +82,7 @@ extension SceneCoordinator {
// Check user authentication status and show onboarding if needs
do {
let request = MastodonAuthentication.sortedFetchRequest
if try appContext.managedObjectContext.fetch(request).isEmpty {
if try appContext.managedObjectContext.count(for: request) == 0 {
DispatchQueue.main.async {
self.present(
scene: .welcome,

View File

@ -0,0 +1,76 @@
//
// CategoryPickerItem.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021/3/5.
//
import Foundation
import MastodonSDK
enum CategoryPickerItem {
case all
case category(category: Mastodon.Entity.Category)
}
extension CategoryPickerItem {
var title: String {
switch self {
case .all:
return L10n.Scene.ServerPicker.Button.Category.all
case .category(let category):
switch category.category {
case .academia:
return "📚"
case .activism:
return ""
case .food:
return "🍕"
case .furry:
return "🦁"
case .games:
return "🕹"
case .general:
return "💬"
case .journalism:
return "📰"
case .lgbt:
return "🏳️‍🌈"
case .regional:
return "📍"
case .art:
return "🎨"
case .music:
return "🎼"
case .tech:
return "📱"
case ._other:
return ""
}
}
}
}
extension CategoryPickerItem: Equatable {
static func == (lhs: CategoryPickerItem, rhs: CategoryPickerItem) -> Bool {
switch (lhs, rhs) {
case (.all, .all):
return true
case (.category(let categoryLeft), .category(let categoryRight)):
return categoryLeft.category.rawValue == categoryRight.category.rawValue
default:
return false
}
}
}
extension CategoryPickerItem: Hashable {
func hash(into hasher: inout Hasher) {
switch self {
case .all:
hasher.combine(String(describing: CategoryPickerItem.all.self))
case .category(let category):
hasher.combine(category.category.rawValue)
}
}
}

View File

@ -13,10 +13,10 @@ import MastodonSDK
/// Note: update Equatable when change case
enum Item {
// timeline
case homeTimelineIndex(objectID: NSManagedObjectID, attribute: StatusTimelineAttribute)
case homeTimelineIndex(objectID: NSManagedObjectID, attribute: StatusAttribute)
// normal list
case toot(objectID: NSManagedObjectID, attribute: StatusTimelineAttribute)
case toot(objectID: NSManagedObjectID, attribute: StatusAttribute)
// loader
case homeMiddleLoader(upperTimelineIndexAnchorObjectID: NSManagedObjectID)
@ -30,7 +30,7 @@ protocol StatusContentWarningAttribute {
}
extension Item {
class StatusTimelineAttribute: Hashable, StatusContentWarningAttribute {
class StatusAttribute: Equatable, Hashable, StatusContentWarningAttribute {
var isStatusTextSensitive: Bool
var isStatusSensitive: Bool
@ -42,7 +42,7 @@ extension Item {
self.isStatusSensitive = isStatusSensitive
}
static func == (lhs: Item.StatusTimelineAttribute, rhs: Item.StatusTimelineAttribute) -> Bool {
static func == (lhs: Item.StatusAttribute, rhs: Item.StatusAttribute) -> Bool {
return lhs.isStatusTextSensitive == rhs.isStatusTextSensitive &&
lhs.isStatusSensitive == rhs.isStatusSensitive
}
@ -51,7 +51,6 @@ extension Item {
hasher.combine(isStatusTextSensitive)
hasher.combine(isStatusSensitive)
}
}
}

View File

@ -0,0 +1,69 @@
//
// PickServerItem.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021/3/5.
//
import Foundation
import MastodonSDK
/// Note: update Equatable when change case
enum PickServerItem {
case header
case categoryPicker(items: [CategoryPickerItem])
case search
case server(server: Mastodon.Entity.Server, attribute: ServerItemAttribute)
}
extension PickServerItem {
final class ServerItemAttribute: Equatable, Hashable {
var isLast: Bool
var isExpand: Bool
init(isLast: Bool, isExpand: Bool) {
self.isLast = isLast
self.isExpand = isExpand
}
static func == (lhs: PickServerItem.ServerItemAttribute, rhs: PickServerItem.ServerItemAttribute) -> Bool {
return lhs.isExpand == rhs.isExpand
}
func hash(into hasher: inout Hasher) {
hasher.combine(isExpand)
}
}
}
extension PickServerItem: Equatable {
static func == (lhs: PickServerItem, rhs: PickServerItem) -> Bool {
switch (lhs, rhs) {
case (.header, .header):
return true
case (.categoryPicker(let itemsLeft), .categoryPicker(let itemsRight)):
return itemsLeft == itemsRight
case (.search, .search):
return true
case (.server(let serverLeft, _), .server(let serverRight, _)):
return serverLeft.domain == serverRight.domain
default:
return false
}
}
}
extension PickServerItem: Hashable {
func hash(into hasher: inout Hasher) {
switch self {
case .header:
hasher.combine(String(describing: PickServerItem.header.self))
case .categoryPicker(let items):
hasher.combine(items)
case .search:
hasher.combine(String(describing: PickServerItem.search.self))
case .server(let server, _):
hasher.combine(server.domain)
}
}
}

View File

@ -0,0 +1,67 @@
//
// PollItem.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-2.
//
import Foundation
import CoreData
enum PollItem {
case opion(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 (.opion(let objectIDLeft, _), .opion(let objectIDRight, _)):
return objectIDLeft == objectIDRight
}
}
}
extension PollItem: Hashable {
func hash(into hasher: inout Hasher) {
switch self {
case .opion(let objectID, _):
hasher.combine(objectID)
}
}
}

View File

@ -0,0 +1,47 @@
//
// CategoryPickerSection.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021/3/5.
//
import UIKit
enum CategoryPickerSection: Equatable, Hashable {
case main
}
extension CategoryPickerSection {
static func collectionViewDiffableDataSource(
for collectionView: UICollectionView,
dependency: NeedsDependency
) -> UICollectionViewDiffableDataSource<CategoryPickerSection, CategoryPickerItem> {
UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item -> UICollectionViewCell? in
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: PickServerCategoryCollectionViewCell.self), for: indexPath) as! PickServerCategoryCollectionViewCell
switch item {
case .all:
cell.categoryView.titleLabel.font = .systemFont(ofSize: 17)
case .category:
cell.categoryView.titleLabel.font = .systemFont(ofSize: 28)
}
cell.categoryView.titleLabel.text = item.title
cell.observe(\.isSelected, options: [.initial, .new]) { cell, _ in
if cell.isSelected {
cell.categoryView.bgView.backgroundColor = Asset.Colors.lightBrandBlue.color
cell.categoryView.bgView.applyShadow(color: Asset.Colors.lightBrandBlue.color, alpha: 1, x: 0, y: 0, blur: 4.0)
if case .all = item {
cell.categoryView.titleLabel.textColor = Asset.Colors.lightWhite.color
}
} else {
cell.categoryView.bgView.backgroundColor = Asset.Colors.lightWhite.color
cell.categoryView.bgView.applyShadow(color: Asset.Colors.lightBrandBlue.color, alpha: 0, x: 0, y: 0, blur: 0.0)
if case .all = item {
cell.categoryView.titleLabel.textColor = Asset.Colors.lightBrandBlue.color
}
}
}
.store(in: &cell.observations)
return cell
}
}
}

View File

@ -0,0 +1,133 @@
//
// PickServerSection.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021/3/5.
//
import UIKit
import MastodonSDK
import Kanna
import AlamofireImage
enum PickServerSection: Equatable, Hashable {
case header
case category
case search
case servers
}
extension PickServerSection {
static func tableViewDiffableDataSource(
for tableView: UITableView,
dependency: NeedsDependency,
pickServerCategoriesCellDelegate: PickServerCategoriesCellDelegate,
pickServerSearchCellDelegate: PickServerSearchCellDelegate,
pickServerCellDelegate: PickServerCellDelegate
) -> UITableViewDiffableDataSource<PickServerSection, PickServerItem> {
UITableViewDiffableDataSource(tableView: tableView) { [weak pickServerCategoriesCellDelegate, weak pickServerSearchCellDelegate, weak pickServerCellDelegate] tableView, indexPath, item -> UITableViewCell? in
switch item {
case .header:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerTitleCell.self), for: indexPath) as! PickServerTitleCell
return cell
case .categoryPicker(let items):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerCategoriesCell.self), for: indexPath) as! PickServerCategoriesCell
cell.delegate = pickServerCategoriesCellDelegate
cell.diffableDataSource = CategoryPickerSection.collectionViewDiffableDataSource(
for: cell.collectionView,
dependency: dependency
)
var snapshot = NSDiffableDataSourceSnapshot<CategoryPickerSection, CategoryPickerItem>()
snapshot.appendSections([.main])
snapshot.appendItems(items, toSection: .main)
cell.diffableDataSource?.apply(snapshot, animatingDifferences: false, completion: nil)
return cell
case .search:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerSearchCell.self), for: indexPath) as! PickServerSearchCell
cell.delegate = pickServerSearchCellDelegate
return cell
case .server(let server, let attribute):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerCell.self), for: indexPath) as! PickServerCell
PickServerSection.configure(cell: cell, server: server, attribute: attribute)
cell.delegate = pickServerCellDelegate
return cell
}
}
}
}
extension PickServerSection {
static func configure(cell: PickServerCell, server: Mastodon.Entity.Server, attribute: PickServerItem.ServerItemAttribute) {
cell.domainLabel.text = server.domain
cell.descriptionLabel.text = {
guard let html = try? HTML(html: server.description, encoding: .utf8) else {
return server.description
}
return html.text ?? server.description
}()
cell.langValueLabel.text = server.language.uppercased()
cell.usersValueLabel.text = parseUsersCount(server.totalUsers)
cell.categoryValueLabel.text = server.category.uppercased()
cell.updateExpandMode(mode: attribute.isExpand ? .expand : .collapse)
if attribute.isLast {
cell.containerView.layer.maskedCorners = [
.layerMinXMaxYCorner,
.layerMaxXMaxYCorner
]
cell.containerView.layer.cornerCurve = .continuous
cell.containerView.layer.cornerRadius = MastodonPickServerAppearance.tableViewCornerRadius
} else {
cell.containerView.layer.cornerRadius = 0
}
cell.expandMode
.receive(on: DispatchQueue.main)
.sink { mode in
switch mode {
case .collapse:
// do nothing
break
case .expand:
let placeholderImage = UIImage.placeholder(size: cell.thumbnailImageView.frame.size, color: .systemFill)
.af.imageRounded(withCornerRadius: 3.0, divideRadiusByImageScale: false)
guard let proxiedThumbnail = server.proxiedThumbnail,
let url = URL(string: proxiedThumbnail) else {
cell.thumbnailImageView.image = placeholderImage
cell.thumbnailActivityIdicator.stopAnimating()
return
}
cell.thumbnailImageView.isHidden = false
cell.thumbnailActivityIdicator.startAnimating()
cell.thumbnailImageView.af.setImage(
withURL: url,
placeholderImage: placeholderImage,
filter: AspectScaledToFillSizeWithRoundedCornersFilter(size: cell.thumbnailImageView.frame.size, radius: 3),
imageTransition: .crossDissolve(0.33),
completion: { [weak cell] response in
switch response.result {
case .success, .failure:
cell?.thumbnailActivityIdicator.stopAnimating()
}
}
)
}
}
.store(in: &cell.disposeBag)
}
private static func parseUsersCount(_ usersCount: Int) -> String {
switch usersCount {
case 0..<1000:
return "\(usersCount)"
default:
let usersCountInThousand = Float(usersCount) / 1000.0
return String(format: "%.1fK", usersCountInThousand)
}
}
}

View File

@ -0,0 +1,87 @@
//
// PollSection.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-2.
//
import UIKit
import CoreData
import CoreDataStack
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 .opion(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)
}
return cell
}
}
}
}
extension PollSection {
static func configure(
cell: PollOptionTableViewCell,
pollOption option: PollOption,
pollItemAttribute attribute: PollItem.Attribute
) {
cell.optionLabel.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.checkmarkBackgroundView.isHidden = true
cell.checkmarkImageView.isHidden = true
case .off:
cell.checkmarkBackgroundView.backgroundColor = .systemBackground
cell.checkmarkBackgroundView.layer.borderColor = UIColor.systemGray3.cgColor
cell.checkmarkBackgroundView.layer.borderWidth = 1
cell.checkmarkBackgroundView.isHidden = false
cell.checkmarkImageView.isHidden = true
case .on:
cell.checkmarkBackgroundView.backgroundColor = .systemBackground
cell.checkmarkBackgroundView.layer.borderColor = UIColor.clear.cgColor
cell.checkmarkBackgroundView.layer.borderWidth = 0
cell.checkmarkBackgroundView.isHidden = false
cell.checkmarkImageView.isHidden = false
}
}
static func configure(cell: PollOptionTableViewCell, voteState state: PollItem.Attribute.VoteState) {
switch state {
case .hidden:
cell.optionPercentageLabel.isHidden = true
cell.voteProgressStripView.isHidden = true
cell.voteProgressStripView.setProgress(0.0, animated: false)
case .reveal(let voted, let percentage, let animated):
cell.optionPercentageLabel.isHidden = false
cell.optionPercentageLabel.text = String(Int(100 * percentage)) + "%"
cell.voteProgressStripView.isHidden = false
cell.voteProgressStripView.tintColor = voted ? Asset.Colors.Background.Poll.highlight.color : Asset.Colors.Background.Poll.disabled.color
cell.voteProgressStripView.setProgress(CGFloat(percentage), animated: animated)
}
}
}

View File

@ -21,11 +21,11 @@ extension StatusSection {
dependency: NeedsDependency,
managedObjectContext: NSManagedObjectContext,
timestampUpdatePublisher: AnyPublisher<Date, Never>,
timelinePostTableViewCellDelegate: StatusTableViewCellDelegate,
statusTableViewCellDelegate: StatusTableViewCellDelegate,
timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate?
) -> UITableViewDiffableDataSource<StatusSection, Item> {
UITableViewDiffableDataSource(tableView: tableView) { [weak timelinePostTableViewCellDelegate, weak timelineMiddleLoaderTableViewCellDelegate] tableView, indexPath, item -> UITableViewCell? in
guard let timelinePostTableViewCellDelegate = timelinePostTableViewCellDelegate else { return UITableViewCell() }
UITableViewDiffableDataSource(tableView: tableView) { [weak statusTableViewCellDelegate, weak timelineMiddleLoaderTableViewCellDelegate] tableView, indexPath, item -> UITableViewCell? in
guard let statusTableViewCellDelegate = statusTableViewCellDelegate else { return UITableViewCell() }
switch item {
case .homeTimelineIndex(objectID: let objectID, let attribute):
@ -34,9 +34,17 @@ extension StatusSection {
// configure cell
managedObjectContext.performAndWait {
let timelineIndex = managedObjectContext.object(with: objectID) as! HomeTimelineIndex
StatusSection.configure(cell: cell, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, toot: timelineIndex.toot, requestUserID: timelineIndex.userID, statusContentWarningAttribute: attribute)
StatusSection.configure(
cell: cell,
dependency: dependency,
readableLayoutFrame: tableView.readableContentGuide.layoutFrame,
timestampUpdatePublisher: timestampUpdatePublisher,
toot: timelineIndex.toot,
requestUserID: timelineIndex.userID,
statusItemAttribute: attribute
)
}
cell.delegate = timelinePostTableViewCellDelegate
cell.delegate = statusTableViewCellDelegate
return cell
case .toot(let objectID, let attribute):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell
@ -45,9 +53,17 @@ extension StatusSection {
// configure cell
managedObjectContext.performAndWait {
let toot = managedObjectContext.object(with: objectID) as! Toot
StatusSection.configure(cell: cell, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, toot: toot, requestUserID: requestUserID, statusContentWarningAttribute: attribute)
StatusSection.configure(
cell: cell,
dependency: dependency,
readableLayoutFrame: tableView.readableContentGuide.layoutFrame,
timestampUpdatePublisher: timestampUpdatePublisher,
toot: toot,
requestUserID: requestUserID,
statusItemAttribute: attribute
)
}
cell.delegate = timelinePostTableViewCellDelegate
cell.delegate = statusTableViewCellDelegate
return cell
case .publicMiddleLoader(let upperTimelineTootID):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self), for: indexPath) as! TimelineMiddleLoaderTableViewCell
@ -66,37 +82,50 @@ extension StatusSection {
}
}
}
}
extension StatusSection {
static func configure(
cell: StatusTableViewCell,
dependency: NeedsDependency,
readableLayoutFrame: CGRect?,
timestampUpdatePublisher: AnyPublisher<Date, Never>,
toot: Toot,
requestUserID: String,
statusContentWarningAttribute: StatusContentWarningAttribute?
statusItemAttribute: Item.StatusAttribute
) {
// set header
cell.statusView.headerContainerStackView.isHidden = toot.reblog == nil
cell.statusView.headerInfoLabel.text = {
let author = toot.author
let name = author.displayName.isEmpty ? author.username : author.displayName
return L10n.Common.Controls.Status.userBoosted(name)
return L10n.Common.Controls.Status.userReblogged(name)
}()
// set name username avatar
// set name username
cell.statusView.nameLabel.text = {
let author = (toot.reblog ?? toot).author
return author.displayName.isEmpty ? author.username : author.displayName
}()
cell.statusView.usernameLabel.text = "@" + (toot.reblog ?? toot).author.acct
cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: (toot.reblog ?? toot).author.avatarImageURL()))
// set avatar
if let reblog = toot.reblog {
cell.statusView.avatarButton.isHidden = true
cell.statusView.avatarStackedContainerButton.isHidden = false
cell.statusView.avatarStackedContainerButton.topLeadingAvatarStackedImageView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: reblog.author.avatarImageURL()))
cell.statusView.avatarStackedContainerButton.bottomTrailingAvatarStackedImageView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: toot.author.avatarImageURL()))
} else {
cell.statusView.avatarButton.isHidden = false
cell.statusView.avatarStackedContainerButton.isHidden = true
cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: toot.author.avatarImageURL()))
}
// set text
cell.statusView.activeTextLabel.config(content: (toot.reblog ?? toot).content)
// set status text content warning
let spoilerText = (toot.reblog ?? toot).spoilerText ?? ""
let isStatusTextSensitive = statusContentWarningAttribute?.isStatusTextSensitive ?? !spoilerText.isEmpty
let isStatusTextSensitive = statusItemAttribute.isStatusTextSensitive
cell.statusView.isStatusTextSensitive = isStatusTextSensitive
cell.statusView.updateContentWarningDisplay(isHidden: !isStatusTextSensitive)
cell.statusView.contentWarningTitle.text = {
@ -124,22 +153,22 @@ extension StatusSection {
}()
let scale: CGFloat = {
switch mosiacImageViewModel.metas.count {
case 1: return 1.3
default: return 0.7
case 1: return 1.3
default: return 0.7
}
}()
return CGSize(width: maxWidth, height: maxWidth * scale)
}()
if mosiacImageViewModel.metas.count == 1 {
let meta = mosiacImageViewModel.metas[0]
let imageView = cell.statusView.statusMosaicImageView.setupImageView(aspectRatio: meta.size, maxSize: imageViewMaxSize)
let imageView = cell.statusView.statusMosaicImageViewContainer.setupImageView(aspectRatio: meta.size, maxSize: imageViewMaxSize)
imageView.af.setImage(
withURL: meta.url,
placeholderImage: UIImage.placeholder(color: .systemFill),
imageTransition: .crossDissolve(0.2)
)
} else {
let imageViews = cell.statusView.statusMosaicImageView.setupImageViews(count: mosiacImageViewModel.metas.count, maxHeight: imageViewMaxSize.height)
let imageViews = cell.statusView.statusMosaicImageViewContainer.setupImageViews(count: mosiacImageViewModel.metas.count, maxHeight: imageViewMaxSize.height)
for (i, imageView) in imageViews.enumerated() {
let meta = mosiacImageViewModel.metas[i]
imageView.af.setImage(
@ -149,25 +178,102 @@ extension StatusSection {
)
}
}
cell.statusView.statusMosaicImageView.isHidden = mosiacImageViewModel.metas.isEmpty
let isStatusSensitive = statusContentWarningAttribute?.isStatusSensitive ?? (toot.reblog ?? toot).sensitive
cell.statusView.statusMosaicImageView.blurVisualEffectView.effect = isStatusSensitive ? MosaicImageViewContainer.blurVisualEffect : nil
cell.statusView.statusMosaicImageView.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0
// toolbar
let replyCountTitle: String = {
let count = (toot.reblog ?? toot).repliesCount?.intValue ?? 0
return StatusSection.formattedNumberTitleForActionButton(count)
}()
cell.statusView.actionToolbarContainer.replyButton.setTitle(replyCountTitle, for: .normal)
cell.statusView.statusMosaicImageViewContainer.isHidden = mosiacImageViewModel.metas.isEmpty
let isStatusSensitive = statusItemAttribute.isStatusSensitive
cell.statusView.statusMosaicImageViewContainer.contentWarningOverlayView.blurVisualEffectView.effect = isStatusSensitive ? ContentWarningOverlayView.blurVisualEffect : nil
cell.statusView.statusMosaicImageViewContainer.contentWarningOverlayView.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0
cell.statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isUserInteractionEnabled = isStatusSensitive
let isLike = (toot.reblog ?? toot).favouritedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false
let favoriteCountTitle: String = {
let count = (toot.reblog ?? toot).favouritesCount.intValue
return StatusSection.formattedNumberTitleForActionButton(count)
// set audio
if let audioAttachment = mediaAttachments.filter({ $0.type == .audio }).first {
cell.statusView.audioView.isHidden = false
AudioContainerViewModel.configure(cell: cell, audioAttachment: audioAttachment, audioService: dependency.context.audioPlaybackService)
} else {
cell.statusView.audioView.isHidden = true
}
// set GIF & video
let playerViewMaxSize: CGSize = {
let maxWidth: CGFloat = {
// use statusView width as container width
// that width follows readable width and keep constant width after rotate
let containerFrame = readableLayoutFrame ?? cell.statusView.frame
return containerFrame.width
}()
let scale: CGFloat = 1.3
return CGSize(width: maxWidth, height: maxWidth * scale)
}()
cell.statusView.actionToolbarContainer.starButton.setTitle(favoriteCountTitle, for: .normal)
cell.statusView.actionToolbarContainer.isStarButtonHighlight = isLike
cell.statusView.playerContainerView.contentWarningOverlayView.blurVisualEffectView.effect = isStatusSensitive ? ContentWarningOverlayView.blurVisualEffect : nil
cell.statusView.playerContainerView.contentWarningOverlayView.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0
cell.statusView.playerContainerView.contentWarningOverlayView.isUserInteractionEnabled = isStatusSensitive
if let videoAttachment = mediaAttachments.filter({ $0.type == .gifv || $0.type == .video }).first,
let videoPlayerViewModel = dependency.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: videoAttachment)
{
let parent = cell.delegate?.parent()
let playerContainerView = cell.statusView.playerContainerView
let playerViewController = playerContainerView.setupPlayer(
aspectRatio: videoPlayerViewModel.videoSize,
maxSize: playerViewMaxSize,
parent: parent
)
playerViewController.delegate = cell.delegate?.playerViewControllerDelegate
playerViewController.player = videoPlayerViewModel.player
playerViewController.showsPlaybackControls = videoPlayerViewModel.videoKind != .gif
playerContainerView.setMediaKind(kind: videoPlayerViewModel.videoKind)
if videoPlayerViewModel.videoKind == .gif {
playerContainerView.setMediaIndicator(isHidden: false)
} else {
videoPlayerViewModel.timeControlStatus.sink { timeControlStatus in
UIView.animate(withDuration: 0.33) {
switch timeControlStatus {
case .playing:
playerContainerView.setMediaIndicator(isHidden: true)
case .paused, .waitingToPlayAtSpecifiedRate:
playerContainerView.setMediaIndicator(isHidden: false)
@unknown default:
assertionFailure()
}
}
}
.store(in: &cell.disposeBag)
}
playerContainerView.isHidden = false
} else {
cell.statusView.playerContainerView.playerViewController.player?.pause()
cell.statusView.playerContainerView.playerViewController.player = nil
}
// set poll
let poll = (toot.reblog ?? toot).poll
StatusSection.configurePoll(
cell: cell,
poll: poll,
requestUserID: requestUserID,
updateProgressAnimated: false,
timestampUpdatePublisher: timestampUpdatePublisher
)
if let poll = poll {
ManagedObjectObserver.observe(object: poll)
.sink { _ in
// do nothing
} receiveValue: { change in
guard case .update(let object) = change.changeType,
let newPoll = object as? Poll else { return }
StatusSection.configurePoll(
cell: cell,
poll: newPoll,
requestUserID: requestUserID,
updateProgressAnimated: true,
timestampUpdatePublisher: timestampUpdatePublisher
)
}
.store(in: &cell.disposeBag)
}
// toolbar
StatusSection.configureActionToolBar(cell: cell, toot: toot, requestUserID: requestUserID)
// set date
let createdAt = (toot.reblog ?? toot).createdAt
@ -185,18 +291,155 @@ extension StatusSection {
// do nothing
} receiveValue: { change in
guard case .update(let object) = change.changeType,
let newToot = object as? Toot else { return }
let targetToot = newToot.reblog ?? newToot
let isLike = targetToot.favouritedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false
let favoriteCount = targetToot.favouritesCount.intValue
let favoriteCountTitle = StatusSection.formattedNumberTitleForActionButton(favoriteCount)
cell.statusView.actionToolbarContainer.starButton.setTitle(favoriteCountTitle, for: .normal)
cell.statusView.actionToolbarContainer.isStarButtonHighlight = isLike
os_log("%{public}s[%{public}ld], %{public}s: like count label for toot %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, targetToot.id, favoriteCount)
let toot = object as? Toot else { return }
StatusSection.configureActionToolBar(cell: cell, toot: toot, requestUserID: requestUserID)
os_log("%{public}s[%{public}ld], %{public}s: reblog count label for toot %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, toot.id, toot.reblogsCount.intValue)
os_log("%{public}s[%{public}ld], %{public}s: like count label for toot %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, toot.id, toot.favouritesCount.intValue)
}
.store(in: &cell.disposeBag)
}
static func configureActionToolBar(
cell: StatusTableViewCell,
toot: Toot,
requestUserID: String
) {
let toot = toot.reblog ?? toot
// set reply
let replyCountTitle: String = {
let count = toot.repliesCount?.intValue ?? 0
return StatusSection.formattedNumberTitleForActionButton(count)
}()
cell.statusView.actionToolbarContainer.replyButton.setTitle(replyCountTitle, for: .normal)
// set reblog
let isReblogged = toot.rebloggedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false
let reblogCountTitle: String = {
let count = toot.reblogsCount.intValue
return StatusSection.formattedNumberTitleForActionButton(count)
}()
cell.statusView.actionToolbarContainer.reblogButton.setTitle(reblogCountTitle, for: .normal)
cell.statusView.actionToolbarContainer.isReblogButtonHighlight = isReblogged
// set like
let isLike = toot.favouritedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false
let favoriteCountTitle: String = {
let count = toot.favouritesCount.intValue
return StatusSection.formattedNumberTitleForActionButton(count)
}()
cell.statusView.actionToolbarContainer.favoriteButton.setTitle(favoriteCountTitle, for: .normal)
cell.statusView.actionToolbarContainer.isFavoriteButtonHighlight = isLike
}
static func configurePoll(
cell: StatusTableViewCell,
poll: Poll?,
requestUserID: String,
updateProgressAnimated: Bool,
timestampUpdatePublisher: AnyPublisher<Date, Never>
) {
guard let poll = poll,
let managedObjectContext = poll.managedObjectContext
else {
cell.statusView.pollTableView.isHidden = true
cell.statusView.pollStatusStackView.isHidden = true
cell.statusView.pollVoteButton.isHidden = true
return
}
cell.statusView.pollTableView.isHidden = false
cell.statusView.pollStatusStackView.isHidden = false
cell.statusView.pollVoteCountLabel.text = {
if poll.multiple {
let count = poll.votersCount?.intValue ?? 0
if count > 1 {
return L10n.Common.Controls.Status.Poll.VoterCount.single(count)
} else {
return L10n.Common.Controls.Status.Poll.VoterCount.multiple(count)
}
} else {
let count = poll.votesCount.intValue
if count > 1 {
return L10n.Common.Controls.Status.Poll.VoteCount.single(count)
} else {
return L10n.Common.Controls.Status.Poll.VoteCount.multiple(count)
}
}
}()
if poll.expired {
cell.pollCountdownSubscription = nil
cell.statusView.pollCountdownLabel.text = L10n.Common.Controls.Status.Poll.closed
} else if let expiresAt = poll.expiresAt {
cell.statusView.pollCountdownLabel.text = L10n.Common.Controls.Status.Poll.timeLeft(expiresAt.shortTimeAgoSinceNow)
cell.pollCountdownSubscription = timestampUpdatePublisher
.sink { _ in
cell.statusView.pollCountdownLabel.text = L10n.Common.Controls.Status.Poll.timeLeft(expiresAt.shortTimeAgoSinceNow)
}
} else {
assertionFailure()
cell.pollCountdownSubscription = nil
cell.statusView.pollCountdownLabel.text = "-"
}
cell.statusView.pollTableView.allowsSelection = !poll.expired
let votedOptions = poll.options.filter { option in
(option.votedBy ?? Set()).map(\.id).contains(requestUserID)
}
let didVotedLocal = !votedOptions.isEmpty
let didVotedRemote = (poll.votedBy ?? Set()).map(\.id).contains(requestUserID)
cell.statusView.pollVoteButton.isEnabled = didVotedLocal
cell.statusView.pollVoteButton.isHidden = !poll.multiple ? true : (didVotedRemote || poll.expired)
cell.statusView.pollTableViewDataSource = PollSection.tableViewDiffableDataSource(
for: cell.statusView.pollTableView,
managedObjectContext: managedObjectContext
)
var snapshot = NSDiffableDataSourceSnapshot<PollSection, PollItem>()
snapshot.appendSections([.main])
let pollItems = poll.options
.sorted(by: { $0.index.intValue < $1.index.intValue })
.map { option -> PollItem in
let attribute: PollItem.Attribute = {
let selectState: PollItem.Attribute.SelectState = {
// check didVotedRemote later to make the local change possible
if !votedOptions.isEmpty {
return votedOptions.contains(option) ? .on : .off
} else if poll.expired {
return .none
} else if didVotedRemote, votedOptions.isEmpty {
return .none
} else {
return .off
}
}()
let voteState: PollItem.Attribute.VoteState = {
var needsReveal: Bool
if poll.expired {
needsReveal = true
} else if didVotedRemote {
needsReveal = true
} else {
needsReveal = false
}
guard needsReveal else { return .hidden }
let percentage: Double = {
guard poll.votesCount.intValue > 0 else { return 0.0 }
return Double(option.votesCount?.intValue ?? 0) / Double(poll.votesCount.intValue)
}()
let voted = votedOptions.isEmpty ? true : votedOptions.contains(option)
return .reveal(voted: voted, percentage: percentage, animated: updateProgressAnimated)
}()
return PollItem.Attribute(selectState: selectState, voteState: voteState)
}()
let option = PollItem.opion(objectID: option.objectID, attribute: attribute)
return option
}
snapshot.appendItems(pollItems, toSection: .main)
cell.statusView.pollTableViewDataSource?.apply(snapshot, animatingDifferences: false, completion: nil)
}
}
extension StatusSection {

View File

@ -0,0 +1,22 @@
//
// AVPlayer.swift
// Mastodon
//
// Created by xiaojian sun on 2021/3/10.
//
import AVKit
// MARK: - CustomDebugStringConvertible
extension AVPlayer.TimeControlStatus: CustomDebugStringConvertible {
public var debugDescription: String {
switch self {
case .paused: return "paused"
case .waitingToPlayAtSpecifiedRate: return "waitingToPlayAtSpecifiedRate"
case .playing: return "playing"
@unknown default:
assertionFailure()
return ""
}
}
}

View File

@ -0,0 +1,51 @@
//
// CALayer.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021-2-26.
//
import UIKit
extension CALayer {
func setupShadow(
color: UIColor = .black,
alpha: Float = 0.5,
x: CGFloat = 0,
y: CGFloat = 2,
blur: CGFloat = 4,
spread: CGFloat = 0,
roundedRect: CGRect? = nil,
byRoundingCorners corners: UIRectCorner? = nil,
cornerRadii: CGSize? = nil
) {
// assert(roundedRect != .zero)
shadowColor = color.cgColor
shadowOpacity = alpha
shadowOffset = CGSize(width: x, height: y)
shadowRadius = blur / 2
rasterizationScale = UIScreen.main.scale
shouldRasterize = true
masksToBounds = false
guard let roundedRect = roundedRect,
let corners = corners,
let cornerRadii = cornerRadii else {
return
}
if spread == 0 {
shadowPath = UIBezierPath(roundedRect: roundedRect, byRoundingCorners: corners, cornerRadii: cornerRadii).cgPath
} else {
let rect = roundedRect.insetBy(dx: -spread, dy: -spread)
shadowPath = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: cornerRadii).cgPath
}
}
func removeShadow() {
shadowRadius = 0
}
}

View File

@ -0,0 +1,19 @@
//
// Double.swift
// Mastodon
//
// Created by sxiaojian on 2021/3/8.
//
import Foundation
extension Double {
func asString(style: DateComponentsFormatter.UnitsStyle) -> String {
let formatter = DateComponentsFormatter()
formatter.allowedUnits = [.minute, .second]
formatter.unitsStyle = style
formatter.zeroFormattingBehavior = .pad
guard let formattedString = formatter.string(from: self) else { return "" }
return formattedString
}
}

View File

@ -1,100 +0,0 @@
//
// Mastodon+Entity+ErrorDetailReason.swift
// Mastodon
//
// Created by sxiaojian on 2021/3/1.
//
import MastodonSDK
extension Mastodon.Entity.ErrorDetailReason {
func localizedDescription() -> String {
switch self.error {
case .ERR_BLOCKED:
return L10n.Common.Errors.errBlocked
case .ERR_UNREACHABLE:
return L10n.Common.Errors.errUnreachable
case .ERR_TAKEN:
return L10n.Common.Errors.errTaken
case .ERR_RESERVED:
return L10n.Common.Errors.errReserved
case .ERR_ACCEPTED:
return L10n.Common.Errors.errAccepted
case .ERR_BLANK:
return L10n.Common.Errors.errBlank
case .ERR_INVALID:
return L10n.Common.Errors.errInvalid
case .ERR_TOO_LONG:
return L10n.Common.Errors.errTooLong
case .ERR_TOO_SHORT:
return L10n.Common.Errors.errTooShort
case .ERR_INCLUSION:
return L10n.Common.Errors.errInclusion
case ._other:
return self.errorDescription ?? ""
}
}
}
extension Mastodon.Entity.ErrorDetail {
func localizedDescription() -> String {
var messages: [String?] = []
if let username = self.username, !username.isEmpty {
let errors = username.map { errorDetailReason -> String in
switch errorDetailReason.error {
case .ERR_INVALID:
return L10n.Common.Errors.Itemdetail.usernameInvalid
case .ERR_TOO_LONG:
return L10n.Common.Errors.Itemdetail.usernameTooLong
default:
return L10n.Common.Errors.Item.username + " " + errorDetailReason.localizedDescription()
}
}
messages.append(contentsOf: errors)
}
if let email = self.email, !email.isEmpty {
let errors = email.map { errorDetailReason -> String in
if errorDetailReason.error == .ERR_INVALID {
return L10n.Common.Errors.Itemdetail.emailInvalid
} else {
return L10n.Common.Errors.Item.email + " " + errorDetailReason.localizedDescription()
}
}
messages.append(contentsOf: errors)
}
if let password = self.password,!password.isEmpty {
let errors = password.map { errorDetailReason -> String in
if errorDetailReason.error == .ERR_TOO_SHORT {
return L10n.Common.Errors.Itemdetail.passwordTooShrot
} else {
return L10n.Common.Errors.Item.password + " " + errorDetailReason.localizedDescription()
}
}
messages.append(contentsOf: errors)
}
if let agreement = self.agreement, !agreement.isEmpty {
let errors = agreement.map {
L10n.Common.Errors.Item.agreement + " " + $0.localizedDescription()
}
messages.append(contentsOf: errors)
}
if let locale = self.locale, !locale.isEmpty {
let errors = locale.map {
L10n.Common.Errors.Item.locale + " " + $0.localizedDescription()
}
messages.append(contentsOf: errors)
}
if let reason = self.reason, !reason.isEmpty {
let errors = reason.map {
L10n.Common.Errors.Item.reason + " " + $0.localizedDescription()
}
messages.append(contentsOf: errors)
}
let message = messages
.compactMap { $0 }
.joined(separator: ", ")
return message.capitalizingFirstLetter()
}
}

View File

@ -0,0 +1,112 @@
//
// Mastodon+Entity+ErrorDetailReason.swift
// Mastodon
//
// Created by sxiaojian on 2021/3/1.
//
import Foundation
import MastodonSDK
extension Mastodon.Entity.Error.Detail: LocalizedError {
public var failureReason: String? {
let reasons: [[String]] = [
usernameErrorDescriptions,
emailErrorDescriptions,
passwordErrorDescriptions,
agreementErrorDescriptions,
localeErrorDescriptions,
reasonErrorDescriptions,
]
guard !reasons.isEmpty else {
return nil
}
return reasons
.flatMap { $0 }
.joined(separator: "; ")
}
}
extension Mastodon.Entity.Error.Detail {
enum Item: String {
case username
case email
case password
case agreement
case locale
case reason
var localized: String {
switch self {
case .username: return L10n.Scene.Register.Error.Item.username
case .email: return L10n.Scene.Register.Error.Item.email
case .password: return L10n.Scene.Register.Error.Item.password
case .agreement: return L10n.Scene.Register.Error.Item.agreement
case .locale: return L10n.Scene.Register.Error.Item.locale
case .reason: return L10n.Scene.Register.Error.Item.reason
}
}
}
private static func localizeError(item: Item, for reason: Reason) -> String {
switch (item, reason.error) {
case (.username, .ERR_INVALID):
return L10n.Scene.Register.Error.Special.usernameInvalid
case (.username, .ERR_TOO_LONG):
return L10n.Scene.Register.Error.Special.usernameTooLong
case (.email, .ERR_INVALID):
return L10n.Scene.Register.Error.Special.emailInvalid
case (.password, .ERR_TOO_SHORT):
return L10n.Scene.Register.Error.Special.passwordTooShort
case (_, .ERR_BLOCKED): return L10n.Scene.Register.Error.Reason.blocked(item.localized)
case (_, .ERR_UNREACHABLE): return L10n.Scene.Register.Error.Reason.unreachable(item.localized)
case (_, .ERR_TAKEN): return L10n.Scene.Register.Error.Reason.taken(item.localized)
case (_, .ERR_RESERVED): return L10n.Scene.Register.Error.Reason.reserved(item.localized)
case (_, .ERR_ACCEPTED): return L10n.Scene.Register.Error.Reason.accepted(item.localized)
case (_, .ERR_BLANK): return L10n.Scene.Register.Error.Reason.blank(item.localized)
case (_, .ERR_INVALID): return L10n.Scene.Register.Error.Reason.invalid(item.localized)
case (_, .ERR_TOO_LONG): return L10n.Scene.Register.Error.Reason.tooLong(item.localized)
case (_, .ERR_TOO_SHORT): return L10n.Scene.Register.Error.Reason.tooShort(item.localized)
case (_, .ERR_INCLUSION): return L10n.Scene.Register.Error.Reason.inclusion(item.localized)
case (_, ._other(let reason)):
assertionFailure("Needs handle new error description here")
return item.rawValue + " " + reason.description
}
}
var usernameErrorDescriptions: [String] {
guard let username = username, !username.isEmpty else { return [] }
return username.map { Mastodon.Entity.Error.Detail.localizeError(item: .username, for: $0) }
}
var emailErrorDescriptions: [String] {
guard let email = email, !email.isEmpty else { return [] }
return email.map { Mastodon.Entity.Error.Detail.localizeError(item: .email, for: $0) }
}
var passwordErrorDescriptions: [String] {
guard let password = password, !password.isEmpty else { return [] }
return password.map { Mastodon.Entity.Error.Detail.localizeError(item: .password, for: $0) }
}
var agreementErrorDescriptions: [String] {
guard let agreement = agreement, !agreement.isEmpty else { return [] }
return agreement.map { Mastodon.Entity.Error.Detail.localizeError(item: .agreement, for: $0) }
}
var localeErrorDescriptions: [String] {
guard let locale = locale, !locale.isEmpty else { return [] }
return locale.map { Mastodon.Entity.Error.Detail.localizeError(item: .locale, for: $0) }
}
var reasonErrorDescriptions: [String] {
guard let reason = reason, !reason.isEmpty else { return [] }
return reason.map { Mastodon.Entity.Error.Detail.localizeError(item: .reason, for: $0) }
}
}

View File

@ -0,0 +1,41 @@
//
// Mastodon+Entity+Error.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-4.
//
import Foundation
import MastodonSDK
extension Mastodon.API.Error: LocalizedError {
public var errorDescription: String? {
guard let mastodonError = mastodonError else {
return "HTTP \(httpResponseStatus.code)"
}
switch mastodonError {
case .generic(let error):
if let _ = error.details {
return nil // Duplicated with the details
} else {
return error.error
}
}
}
public var failureReason: String? {
guard let mastodonError = mastodonError else {
return httpResponseStatus.reasonPhrase
}
switch mastodonError {
case .generic(let error):
if let details = error.details {
return details.failureReason
} else {
return error.errorDescription
}
}
}
}

View File

@ -0,0 +1,20 @@
//
// NSManagedObjectContext.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-11.
//
import Foundation
import CoreData
extension NSManagedObjectContext {
func safeFetch<T>(_ request: NSFetchRequest<T>) -> [T] where T : NSFetchRequestResult {
do {
return try fetch(request)
} catch {
assertionFailure(error.localizedDescription)
return []
}
}
}

View File

@ -42,44 +42,3 @@ extension UIAlertController {
)
}
}
extension UIAlertController {
convenience init(
for error: Mastodon.API.Error,
title: String?,
preferredStyle: UIAlertController.Style
) {
let _title: String
let message: String?
switch error.mastodonError {
case .generic(let mastodonEntityError):
if let title = title {
_title = title
} else {
_title = error.errorDescription ?? "Error"
}
var messages: [String?] = []
if let details = mastodonEntityError.details {
message = details.localizedDescription()
} else {
messages.append(contentsOf: [
error.failureReason,
error.recoverySuggestion
])
message = messages
.compactMap { $0 }
.joined(separator: " ")
}
default:
_title = "Internal Error"
message = error.localizedDescription
}
self.init(
title: _title,
message: message,
preferredStyle: preferredStyle
)
}
}

View File

@ -0,0 +1,64 @@
//
// UIControl.swift
// Mastodon
//
// Created by sxiaojian on 2021/3/8.
//
import Foundation
import UIKit
import Combine
/// A custom subscription to capture UIControl target events.
final class UIControlSubscription<SubscriberType: Subscriber, Control: UIControl>: Subscription where SubscriberType.Input == Control {
private var subscriber: SubscriberType?
private let control: Control
init(subscriber: SubscriberType, control: Control, event: UIControl.Event) {
self.subscriber = subscriber
self.control = control
control.addTarget(self, action: #selector(eventHandler), for: event)
}
func request(_ demand: Subscribers.Demand) {
// We do nothing here as we only want to send events when they occur.
// See, for more info: https://developer.apple.com/documentation/combine/subscribers/demand
}
func cancel() {
subscriber = nil
}
@objc private func eventHandler() {
_ = subscriber?.receive(control)
}
}
/// A custom `Publisher` to work with our custom `UIControlSubscription`.
struct UIControlPublisher<Control: UIControl>: Publisher {
typealias Output = Control
typealias Failure = Never
let control: Control
let controlEvents: UIControl.Event
init(control: Control, events: UIControl.Event) {
self.control = control
self.controlEvents = events
}
func receive<S>(subscriber: S) where S : Subscriber, S.Failure == UIControlPublisher.Failure, S.Input == UIControlPublisher.Output {
let subscription = UIControlSubscription(subscriber: subscriber, control: control, event: controlEvents)
subscriber.receive(subscription: subscription)
}
}
/// Extending the `UIControl` types to be able to produce a `UIControl.Event` publisher.
protocol CombineCompatible { }
extension UIControl: CombineCompatible { }
extension CombineCompatible where Self: UIControl {
func publisher(for events: UIControl.Event) -> UIControlPublisher<UIControl> {
return UIControlPublisher(control: self, events: events)
}
}

View File

@ -1,25 +1,23 @@
//
// UIIamge.swift
// UIImage.swift
// Mastodon
//
// Created by sxiaojian on 2021/1/28.
// Created by sxiaojian on 2021/3/8.
//
import UIKit
import CoreImage
import CoreImage.CIFilterBuiltins
import UIKit
extension UIImage {
static func placeholder(size: CGSize = CGSize(width: 1, height: 1), color: UIColor) -> UIImage {
let render = UIGraphicsImageRenderer(size: size)
return render.image { (context: UIGraphicsImageRendererContext) in
context.cgContext.setFillColor(color.cgColor)
context.fill(CGRect(origin: .zero, size: size))
}
}
}
// refs: https://www.hackingwithswift.com/example-code/media/how-to-read-the-average-color-of-a-uiimage-using-ciareaaverage
@ -27,16 +25,16 @@ extension UIImage {
@available(iOS 14.0, *)
var dominantColor: UIColor? {
guard let inputImage = CIImage(image: self) else { return nil }
let filter = CIFilter.areaAverage()
filter.inputImage = inputImage
filter.extent = inputImage.extent
guard let outputImage = filter.outputImage else { return nil }
var bitmap = [UInt8](repeating: 0, count: 4)
let context = CIContext(options: [.workingColorSpace: kCFNull])
context.render(outputImage, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil)
return UIColor(red: CGFloat(bitmap[0]) / 255, green: CGFloat(bitmap[1]) / 255, blue: CGFloat(bitmap[2]) / 255, alpha: CGFloat(bitmap[3]) / 255)
}
}
@ -53,3 +51,20 @@ extension UIImage {
return image
}
}
public extension UIImage {
func withRoundedCorners(radius: CGFloat? = nil) -> UIImage? {
let maxRadius = min(size.width, size.height) / 2
let cornerRadius: CGFloat = {
guard let radius = radius, radius > 0 else { return maxRadius }
return min(radius, maxRadius)
}()
let render = UIGraphicsImageRenderer(size: size)
return render.image { (_: UIGraphicsImageRendererContext) in
let rect = CGRect(origin: .zero, size: size)
UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius).addClip()
draw(in: rect)
}
}
}

View File

@ -0,0 +1,55 @@
//
// UITableView.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021-3-2.
//
import UIKit
extension UITableView {
// static let groupedTableViewPaddingHeaderViewHeight: CGFloat = 16
// static var groupedTableViewPaddingHeaderView: UIView {
// return UIView(frame: CGRect(x: 0, y: 0, width: 100, height: groupedTableViewPaddingHeaderViewHeight))
// }
}
extension UITableView {
func deselectRow(with transitionCoordinator: UIViewControllerTransitionCoordinator?, animated: Bool) {
guard let indexPathForSelectedRow = indexPathForSelectedRow else { return }
guard let transitionCoordinator = transitionCoordinator else {
deselectRow(at: indexPathForSelectedRow, animated: animated)
return
}
transitionCoordinator.animate(alongsideTransition: { _ in
self.deselectRow(at: indexPathForSelectedRow, animated: animated)
}, completion: { context in
if context.isCancelled {
self.selectRow(at: indexPathForSelectedRow, animated: animated, scrollPosition: .none)
}
})
}
func blinkRow(at indexPath: IndexPath) {
DispatchQueue.main.asyncAfter(wallDeadline: .now() + 1) { [weak self] in
guard let self = self else { return }
guard let cell = self.cellForRow(at: indexPath) else { return }
let backgroundColor = cell.backgroundColor
UIView.animate(withDuration: 0.3) {
cell.backgroundColor = Asset.Colors.Label.highlight.color.withAlphaComponent(0.5)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
UIView.animate(withDuration: 0.3) {
cell.backgroundColor = backgroundColor
}
}
}
}
}
}

View File

@ -28,8 +28,16 @@ internal enum Asset {
internal enum Asset {
internal static let mastodonTextLogo = ImageAsset(name: "Asset/mastodon.text.logo")
}
internal enum Circles {
internal static let plusCircleFill = ImageAsset(name: "Circles/plus.circle.fill")
}
internal enum Colors {
internal enum Background {
internal enum Poll {
internal static let disabled = ColorAsset(name: "Colors/Background/Poll/disabled")
internal static let highlight = ColorAsset(name: "Colors/Background/Poll/highlight")
}
internal static let mediaTypeIndicotor = ColorAsset(name: "Colors/Background/mediaTypeIndicotor")
internal static let onboardingBackground = ColorAsset(name: "Colors/Background/onboarding.background")
internal static let secondaryGroupedSystemBackground = ColorAsset(name: "Colors/Background/secondary.grouped.system.background")
internal static let secondarySystemBackground = ColorAsset(name: "Colors/Background/secondary.system.background")
@ -51,6 +59,9 @@ internal enum Asset {
internal static let primary = ColorAsset(name: "Colors/Label/primary")
internal static let secondary = ColorAsset(name: "Colors/Label/secondary")
}
internal enum Slider {
internal static let bar = ColorAsset(name: "Colors/Slider/bar")
}
internal enum TextField {
internal static let highlight = ColorAsset(name: "Colors/TextField/highlight")
internal static let invalid = ColorAsset(name: "Colors/TextField/invalid")
@ -66,6 +77,8 @@ internal enum Asset {
internal static let lightSecondaryText = ColorAsset(name: "Colors/lightSecondaryText")
internal static let lightSuccessGreen = ColorAsset(name: "Colors/lightSuccessGreen")
internal static let lightWhite = ColorAsset(name: "Colors/lightWhite")
internal static let plusCircleFill = ImageAsset(name: "Colors/plus.circle.fill")
internal static let systemGreen = ColorAsset(name: "Colors/system.green")
internal static let systemOrange = ColorAsset(name: "Colors/system.orange")
}
internal enum Welcome {
@ -84,7 +97,6 @@ internal enum Asset {
}
internal static let mastodonLogo = ImageAsset(name: "Welcome/mastodon.logo")
internal static let mastodonLogoLarge = ImageAsset(name: "Welcome/mastodon.logo.large")
internal static let welcomeLogo = ImageAsset(name: "Welcome/welcome.logo")
}
}
// swiftlint:enable identifier_name line_length nesting type_body_length type_name

View File

@ -13,6 +13,12 @@ internal enum L10n {
internal enum Common {
internal enum Alerts {
internal enum Common {
/// Please try again.
internal static let pleaseTryAgain = L10n.tr("Localizable", "Common.Alerts.Common.PleaseTryAgain")
/// Please try again later.
internal static let pleaseTryAgainLater = L10n.tr("Localizable", "Common.Alerts.Common.PleaseTryAgainLater")
}
internal enum ServerError {
/// Server Error
internal static let title = L10n.tr("Localizable", "Common.Alerts.ServerError.Title")
@ -21,6 +27,12 @@ internal enum L10n {
/// Sign Up Failure
internal static let title = L10n.tr("Localizable", "Common.Alerts.SignUpFailure.Title")
}
internal enum VoteFailure {
/// The poll has expired
internal static let pollExpired = L10n.tr("Localizable", "Common.Alerts.VoteFailure.PollExpired")
/// Vote Failure
internal static let title = L10n.tr("Localizable", "Common.Alerts.VoteFailure.Title")
}
}
internal enum Controls {
internal enum Actions {
@ -64,9 +76,39 @@ internal enum L10n {
internal static let showPost = L10n.tr("Localizable", "Common.Controls.Status.ShowPost")
/// content warning
internal static let statusContentWarning = L10n.tr("Localizable", "Common.Controls.Status.StatusContentWarning")
/// %@ boosted
internal static func userBoosted(_ p1: Any) -> String {
return L10n.tr("Localizable", "Common.Controls.Status.UserBoosted", String(describing: p1))
/// %@ reblogged
internal static func userReblogged(_ p1: Any) -> String {
return L10n.tr("Localizable", "Common.Controls.Status.UserReblogged", String(describing: p1))
}
internal enum Poll {
/// Closed
internal static let closed = L10n.tr("Localizable", "Common.Controls.Status.Poll.Closed")
/// %@ left
internal static func timeLeft(_ p1: Any) -> String {
return L10n.tr("Localizable", "Common.Controls.Status.Poll.TimeLeft", String(describing: p1))
}
/// Vote
internal static let vote = L10n.tr("Localizable", "Common.Controls.Status.Poll.Vote")
internal enum VoteCount {
/// %d votes
internal static func multiple(_ p1: Int) -> String {
return L10n.tr("Localizable", "Common.Controls.Status.Poll.VoteCount.Multiple", p1)
}
/// %d vote
internal static func single(_ p1: Int) -> String {
return L10n.tr("Localizable", "Common.Controls.Status.Poll.VoteCount.Single", p1)
}
}
internal enum VoterCount {
/// %d voters
internal static func multiple(_ p1: Int) -> String {
return L10n.tr("Localizable", "Common.Controls.Status.Poll.VoterCount.Multiple", p1)
}
/// %d voter
internal static func single(_ p1: Int) -> String {
return L10n.tr("Localizable", "Common.Controls.Status.Poll.VoterCount.Single", p1)
}
}
}
}
internal enum Timeline {
@ -82,52 +124,6 @@ internal enum L10n {
internal static let single = L10n.tr("Localizable", "Common.Countable.Photo.Single")
}
}
internal enum Errors {
/// must be accepted
internal static let errAccepted = L10n.tr("Localizable", "Common.Errors.ErrAccepted")
/// is required
internal static let errBlank = L10n.tr("Localizable", "Common.Errors.ErrBlank")
/// contains a disallowed e-mail provider
internal static let errBlocked = L10n.tr("Localizable", "Common.Errors.ErrBlocked")
/// is not a supported value
internal static let errInclusion = L10n.tr("Localizable", "Common.Errors.ErrInclusion")
/// is invalid
internal static let errInvalid = L10n.tr("Localizable", "Common.Errors.ErrInvalid")
/// is a reserved keyword
internal static let errReserved = L10n.tr("Localizable", "Common.Errors.ErrReserved")
/// is already in use
internal static let errTaken = L10n.tr("Localizable", "Common.Errors.ErrTaken")
/// is too long
internal static let errTooLong = L10n.tr("Localizable", "Common.Errors.ErrTooLong")
/// is too short
internal static let errTooShort = L10n.tr("Localizable", "Common.Errors.ErrTooShort")
/// does not seem to exist
internal static let errUnreachable = L10n.tr("Localizable", "Common.Errors.ErrUnreachable")
internal enum Item {
/// agreement
internal static let agreement = L10n.tr("Localizable", "Common.Errors.Item.Agreement")
/// email
internal static let email = L10n.tr("Localizable", "Common.Errors.Item.Email")
/// locale
internal static let locale = L10n.tr("Localizable", "Common.Errors.Item.Locale")
/// password
internal static let password = L10n.tr("Localizable", "Common.Errors.Item.Password")
/// reason
internal static let reason = L10n.tr("Localizable", "Common.Errors.Item.Reason")
/// username
internal static let username = L10n.tr("Localizable", "Common.Errors.Item.Username")
}
internal enum Itemdetail {
/// This is not a valid e-mail address
internal static let emailInvalid = L10n.tr("Localizable", "Common.Errors.Itemdetail.EmailInvalid")
/// password is too short (must be at least 8 characters)
internal static let passwordTooShrot = L10n.tr("Localizable", "Common.Errors.Itemdetail.PasswordTooShrot")
/// Username must only contain alphanumeric characters and underscores
internal static let usernameInvalid = L10n.tr("Localizable", "Common.Errors.Itemdetail.UsernameInvalid")
/// username is too long ( can't be longer than 30 characters)
internal static let usernameTooLong = L10n.tr("Localizable", "Common.Errors.Itemdetail.UsernameTooLong")
}
}
}
internal enum Scene {
@ -172,12 +168,76 @@ internal enum L10n {
internal static let title = L10n.tr("Localizable", "Scene.PublicTimeline.Title")
}
internal enum Register {
/// Regsiter request sent. Please check your email.
internal static let checkEmail = L10n.tr("Localizable", "Scene.Register.CheckEmail")
/// Success
internal static let success = L10n.tr("Localizable", "Scene.Register.Success")
/// Tell us about you.
internal static let title = L10n.tr("Localizable", "Scene.Register.Title")
internal enum Error {
internal enum Item {
/// Agreement
internal static let agreement = L10n.tr("Localizable", "Scene.Register.Error.Item.Agreement")
/// Email
internal static let email = L10n.tr("Localizable", "Scene.Register.Error.Item.Email")
/// Locale
internal static let locale = L10n.tr("Localizable", "Scene.Register.Error.Item.Locale")
/// Password
internal static let password = L10n.tr("Localizable", "Scene.Register.Error.Item.Password")
/// Reason
internal static let reason = L10n.tr("Localizable", "Scene.Register.Error.Item.Reason")
/// Username
internal static let username = L10n.tr("Localizable", "Scene.Register.Error.Item.Username")
}
internal enum Reason {
/// %@ must be accepted
internal static func accepted(_ p1: Any) -> String {
return L10n.tr("Localizable", "Scene.Register.Error.Reason.Accepted", String(describing: p1))
}
/// %@ is required
internal static func blank(_ p1: Any) -> String {
return L10n.tr("Localizable", "Scene.Register.Error.Reason.Blank", String(describing: p1))
}
/// %@ contains a disallowed e-mail provider
internal static func blocked(_ p1: Any) -> String {
return L10n.tr("Localizable", "Scene.Register.Error.Reason.Blocked", String(describing: p1))
}
/// %@ is not a supported value
internal static func inclusion(_ p1: Any) -> String {
return L10n.tr("Localizable", "Scene.Register.Error.Reason.Inclusion", String(describing: p1))
}
/// %@ is invalid
internal static func invalid(_ p1: Any) -> String {
return L10n.tr("Localizable", "Scene.Register.Error.Reason.Invalid", String(describing: p1))
}
/// %@ is a reserved keyword
internal static func reserved(_ p1: Any) -> String {
return L10n.tr("Localizable", "Scene.Register.Error.Reason.Reserved", String(describing: p1))
}
/// %@ is already in use
internal static func taken(_ p1: Any) -> String {
return L10n.tr("Localizable", "Scene.Register.Error.Reason.Taken", String(describing: p1))
}
/// %@ is too long
internal static func tooLong(_ p1: Any) -> String {
return L10n.tr("Localizable", "Scene.Register.Error.Reason.TooLong", String(describing: p1))
}
/// %@ is too short
internal static func tooShort(_ p1: Any) -> String {
return L10n.tr("Localizable", "Scene.Register.Error.Reason.TooShort", String(describing: p1))
}
/// %@ does not seem to exist
internal static func unreachable(_ p1: Any) -> String {
return L10n.tr("Localizable", "Scene.Register.Error.Reason.Unreachable", String(describing: p1))
}
}
internal enum Special {
/// This is not a valid e-mail address
internal static let emailInvalid = L10n.tr("Localizable", "Scene.Register.Error.Special.EmailInvalid")
/// Password is too short (must be at least 8 characters)
internal static let passwordTooShort = L10n.tr("Localizable", "Scene.Register.Error.Special.PasswordTooShort")
/// Username must only contain alphanumeric characters and underscores
internal static let usernameInvalid = L10n.tr("Localizable", "Scene.Register.Error.Special.UsernameInvalid")
/// Username is too long (can't be longer than 30 characters)
internal static let usernameTooLong = L10n.tr("Localizable", "Scene.Register.Error.Special.UsernameTooLong")
}
}
internal enum Input {
internal enum DisplayName {
/// display name
@ -192,12 +252,10 @@ internal enum L10n {
internal static let registrationUserInviteRequest = L10n.tr("Localizable", "Scene.Register.Input.Invite.RegistrationUserInviteRequest")
}
internal enum Password {
/// Your password needs at least eight characters
internal static let hint = L10n.tr("Localizable", "Scene.Register.Input.Password.Hint")
/// password
internal static let placeholder = L10n.tr("Localizable", "Scene.Register.Input.Password.Placeholder")
/// Your password needs at least:
internal static let prompt = L10n.tr("Localizable", "Scene.Register.Input.Password.Prompt")
/// Eight characters
internal static let promptEightCharacters = L10n.tr("Localizable", "Scene.Register.Input.Password.PromptEightCharacters")
}
internal enum Username {
/// This username is taken.
@ -220,6 +278,12 @@ internal enum L10n {
internal static let all = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.All")
}
}
internal enum EmptyState {
/// Something went wrong while loading data. Check your internet connection.
internal static let badNetwork = L10n.tr("Localizable", "Scene.ServerPicker.EmptyState.BadNetwork")
/// Finding available servers...
internal static let findingServers = L10n.tr("Localizable", "Scene.ServerPicker.EmptyState.FindingServers")
}
internal enum Input {
/// Find a server or join your own...
internal static let placeholder = L10n.tr("Localizable", "Scene.ServerPicker.Input.Placeholder")

View File

@ -23,7 +23,13 @@ extension AvatarConfigurableView {
public func configure(with configuration: AvatarConfigurableViewConfiguration) {
let placeholderImage: UIImage = {
let placeholderImage = configuration.placeholderImage ?? UIImage.placeholder(size: Self.configurableAvatarImageSize, color: .systemFill)
return placeholderImage.af.imageRoundedIntoCircle()
if Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 {
return placeholderImage
.af.imageAspectScaled(toFill: Self.configurableAvatarImageSize)
.af.imageRounded(withCornerRadius: 4, divideRadiusByImageScale: true)
} else {
return placeholderImage.af.imageRoundedIntoCircle()
}
}()
// cancel previous task
@ -65,7 +71,8 @@ extension AvatarConfigurableView {
)
avatarImageView.layer.masksToBounds = true
avatarImageView.layer.cornerRadius = Self.configurableAvatarImageCornerRadius
avatarImageView.layer.cornerCurve = .circular
avatarImageView.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous :.circular
default:
let filter = ScaledToSizeWithRoundedCornersFilter(size: Self.configurableAvatarImageSize, radius: Self.configurableAvatarImageCornerRadius)
avatarImageView.af.setImage(
@ -92,7 +99,7 @@ extension AvatarConfigurableView {
)
avatarButton.layer.masksToBounds = true
avatarButton.layer.cornerRadius = Self.configurableAvatarImageCornerRadius
avatarButton.layer.cornerCurve = .continuous
avatarButton.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous : .circular
default:
let filter = ScaledToSizeWithRoundedCornersFilter(size: Self.configurableAvatarImageSize, radius: Self.configurableAvatarImageCornerRadius)
avatarButton.af.setImage(

View File

@ -0,0 +1,21 @@
//
// NeedsDependency+AVPlayerViewControllerDelegate.swift
// Mastodon
//
// Created by xiaojian sun on 2021/3/10.
//
import Foundation
import AVKit
extension NeedsDependency where Self: AVPlayerViewControllerDelegate {
func handlePlayerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
context.videoPlaybackService.playerViewModel(for: playerViewController)?.isFullScreenPresentationing = true
}
func handlePlayerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
context.videoPlaybackService.playerViewModel(for: playerViewController)?.isFullScreenPresentationing = false
}
}

View File

@ -0,0 +1,194 @@
//
// StatusProvider+StatusTableViewCellDelegate.swift
// Mastodon
//
// Created by sxiaojian on 2021/2/8.
//
import os.log
import UIKit
import Combine
import CoreData
import CoreDataStack
import MastodonSDK
import ActiveLabel
// MARK: - ActionToolbarContainerDelegate
extension StatusTableViewCellDelegate where Self: StatusProvider {
func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, reblogButtonDidPressed sender: UIButton) {
StatusProviderFacade.responseToStatusReblogAction(provider: self, cell: cell)
}
func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) {
StatusProviderFacade.responseToStatusLikeAction(provider: self, cell: cell)
}
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton) {
guard let diffableDataSource = self.tableViewDiffableDataSource else { return }
guard let item = item(for: cell, indexPath: nil) else { return }
switch item {
case .homeTimelineIndex(_, let attribute):
attribute.isStatusTextSensitive = false
case .toot(_, let attribute):
attribute.isStatusTextSensitive = false
default:
return
}
var snapshot = diffableDataSource.snapshot()
snapshot.reloadItems([item])
diffableDataSource.apply(snapshot)
}
}
// MARK: - MosciaImageViewContainerDelegate
extension StatusTableViewCellDelegate where Self: StatusProvider {
func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) {
}
func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) {
statusTableViewCell(cell, contentWarningOverlayViewDidPressed: contentWarningOverlayView)
}
func statusTableViewCell(_ cell: StatusTableViewCell, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) {
contentWarningOverlayView.isUserInteractionEnabled = false
statusTableViewCell(cell, contentWarningOverlayViewDidPressed: contentWarningOverlayView)
}
func statusTableViewCell(_ cell: StatusTableViewCell, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) {
guard let diffableDataSource = self.tableViewDiffableDataSource else { return }
guard let item = item(for: cell, indexPath: nil) else { return }
switch item {
case .homeTimelineIndex(_, let attribute):
attribute.isStatusSensitive = false
case .toot(_, let attribute):
attribute.isStatusSensitive = false
default:
return
}
contentWarningOverlayView.isUserInteractionEnabled = false
var snapshot = diffableDataSource.snapshot()
snapshot.reloadItems([item])
UIView.animate(withDuration: 0.33) {
contentWarningOverlayView.blurVisualEffectView.effect = nil
contentWarningOverlayView.vibrancyVisualEffectView.alpha = 0.0
} completion: { _ in
diffableDataSource.apply(snapshot, animatingDifferences: false, completion: nil)
}
}
}
// MARK: - PollTableView
extension StatusTableViewCellDelegate where Self: StatusProvider {
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, pollVoteButtonPressed button: UIButton) {
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return }
toot(for: cell, indexPath: nil)
.receive(on: DispatchQueue.main)
.setFailureType(to: Error.self)
.compactMap { toot -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Poll>, Error>? in
guard let toot = (toot?.reblog ?? toot) else { return nil }
guard let poll = toot.poll else { return nil }
let votedOptions = poll.options.filter { ($0.votedBy ?? Set()).contains(where: { $0.id == activeMastodonAuthenticationBox.userID }) }
let choices = votedOptions.map { $0.index.intValue }
let domain = poll.toot.domain
button.isEnabled = false
return self.context.apiService.vote(
domain: domain,
pollID: poll.id,
pollObjectID: poll.objectID,
choices: choices,
mastodonAuthenticationBox: activeMastodonAuthenticationBox
)
}
.switchToLatest()
.sink(receiveCompletion: { completion in
switch completion {
case .failure(let error):
// TODO: handle error
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: multiple vote fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
button.isEnabled = true
case .finished:
break
}
}, receiveValue: { response in
// do nothing
})
.store(in: &context.disposeBag)
}
func statusTableViewCell(_ cell: StatusTableViewCell, pollTableView: PollTableView, didSelectRowAt indexPath: IndexPath) {
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return }
guard let activeMastodonAuthentication = context.authenticationService.activeMastodonAuthentication.value else { return }
guard let diffableDataSource = cell.statusView.pollTableViewDataSource else { return }
let item = diffableDataSource.itemIdentifier(for: indexPath)
guard case let .opion(objectID, attribute) = item else { return }
guard let option = managedObjectContext.object(with: objectID) as? PollOption else { return }
let poll = option.poll
let pollObjectID = option.poll.objectID
let domain = poll.toot.domain
if poll.multiple {
var votedOptions = poll.options.filter { ($0.votedBy ?? Set()).contains(where: { $0.id == activeMastodonAuthenticationBox.userID }) }
if votedOptions.contains(option) {
votedOptions.remove(option)
} else {
votedOptions.insert(option)
}
let choices = votedOptions.map { $0.index.intValue }
context.apiService.vote(
pollObjectID: option.poll.objectID,
mastodonUserObjectID: activeMastodonAuthentication.user.objectID,
choices: choices
)
.handleEvents(receiveOutput: { _ in
// TODO: add haptic
})
.receive(on: DispatchQueue.main)
.sink { completion in
// Do nothing
} receiveValue: { _ in
// Do nothing
}
.store(in: &context.disposeBag)
} else {
let choices = [option.index.intValue]
context.apiService.vote(
pollObjectID: pollObjectID,
mastodonUserObjectID: activeMastodonAuthentication.user.objectID,
choices: [option.index.intValue]
)
.handleEvents(receiveOutput: { _ in
// TODO: add haptic
})
.flatMap { pollID -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Poll>, Error> in
return self.context.apiService.vote(
domain: domain,
pollID: pollID,
pollObjectID: pollObjectID,
choices: choices,
mastodonAuthenticationBox: activeMastodonAuthenticationBox
)
}
.receive(on: DispatchQueue.main)
.sink { completion in
} receiveValue: { response in
print(response.value)
}
.store(in: &context.disposeBag)
}
}
}

View File

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

View File

@ -0,0 +1,106 @@
//
// StatusProvider+UITableViewDelegate.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-3.
//
import Combine
import CoreDataStack
import MastodonSDK
import os.log
import UIKit
extension StatusTableViewCellDelegate where Self: StatusProvider {
// TODO:
// func handleTableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
// }
func handleTableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
// update poll when toot appear
let now = Date()
var pollID: Mastodon.Entity.Poll.ID?
toot(for: cell, indexPath: indexPath)
.compactMap { [weak self] toot -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Poll>, Error>? in
guard let self = self else { return nil }
guard let authenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { return nil }
guard let toot = (toot?.reblog ?? toot) else { return nil }
guard let poll = toot.poll else { return nil }
pollID = poll.id
// not expired AND last update > 60s
guard !poll.expired else {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s expired. Skip for update", (#file as NSString).lastPathComponent, #line, #function, poll.id)
return nil
}
let timeIntervalSinceUpdate = now.timeIntervalSince(poll.updatedAt)
#if DEBUG
let autoRefreshTimeInterval: TimeInterval = 3 // speedup testing
#else
let autoRefreshTimeInterval: TimeInterval = 60
#endif
guard timeIntervalSinceUpdate > autoRefreshTimeInterval else {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s updated in the %.2fs. Skip for update", (#file as NSString).lastPathComponent, #line, #function, poll.id, timeIntervalSinceUpdate)
return nil
}
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s info update…", (#file as NSString).lastPathComponent, #line, #function, poll.id)
return self.context.apiService.poll(
domain: toot.domain,
pollID: poll.id,
pollObjectID: poll.objectID,
mastodonAuthenticationBox: authenticationBox
)
}
.setFailureType(to: Error.self)
.switchToLatest()
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { completion in
switch completion {
case .failure(let error):
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s info fail to update: %s", (#file as NSString).lastPathComponent, #line, #function, pollID ?? "?", error.localizedDescription)
case .finished:
break
}
}, receiveValue: { response in
let poll = response.value
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s info updated", (#file as NSString).lastPathComponent, #line, #function, poll.id)
})
.store(in: &disposeBag)
toot(for: cell, indexPath: indexPath)
.sink { [weak self] toot in
guard let self = self else { return }
let toot = toot?.reblog ?? toot
guard let media = (toot?.mediaAttachments ?? Set()).first else { return }
guard let videoPlayerViewModel = self.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: media) else { return }
DispatchQueue.main.async {
videoPlayerViewModel.willDisplay()
}
}
.store(in: &disposeBag)
}
func handleTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
// os_log("%{public}s[%{public}ld], %{public}s: indexPath %s", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription)
toot(for: cell, indexPath: indexPath)
.sink { [weak self] toot in
guard let self = self else { return }
guard let media = (toot?.mediaAttachments ?? Set()).first else { return }
if let videoPlayerViewModel = self.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: media) {
DispatchQueue.main.async {
videoPlayerViewModel.didEndDisplaying()
}
}
if let currentAudioAttachment = self.context.audioPlaybackService.attachment, let _ = toot?.mediaAttachments?.contains(currentAudioAttachment) {
self.context.audioPlaybackService.pause()
}
}
.store(in: &disposeBag)
}
}
extension StatusTableViewCellDelegate where Self: StatusProvider {}

View File

@ -7,13 +7,17 @@
import UIKit
import Combine
import CoreData
import CoreDataStack
protocol StatusProvider: NeedsDependency & DisposeBagCollectable & UIViewController {
// async
func toot() -> Future<Toot?, Never>
func toot(for cell: UITableViewCell, indexPath: IndexPath?) -> Future<Toot?, Never>
func toot(for cell: UICollectionViewCell) -> Future<Toot?, Never>
// sync
var managedObjectContext: NSManagedObjectContext { get }
var tableViewDiffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? { get }
func item(for cell: UITableViewCell, indexPath: IndexPath?) -> Future<Item?, Never>
func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item?
}

View File

@ -16,6 +16,7 @@ import ActiveLabel
enum StatusProviderFacade {
}
extension StatusProviderFacade {
static func responseToStatusLikeAction(provider: StatusProvider) {
@ -56,10 +57,9 @@ extension StatusProviderFacade {
toot
.compactMap { toot -> (NSManagedObjectID, Mastodon.API.Favorites.FavoriteKind)? in
guard let toot = toot else { return nil }
guard let toot = toot?.reblog ?? toot else { return nil }
let favoriteKind: Mastodon.API.Favorites.FavoriteKind = {
let targetToot = (toot.reblog ?? toot)
let isLiked = targetToot.favouritedBy.flatMap { $0.contains(where: { $0.id == mastodonUserID }) } ?? false
let isLiked = toot.favouritedBy.flatMap { $0.contains(where: { $0.id == mastodonUserID }) } ?? false
return isLiked ? .destroy : .create
}()
return (toot.objectID, favoriteKind)
@ -120,6 +120,115 @@ extension StatusProviderFacade {
}
extension StatusProviderFacade {
static func responseToStatusReblogAction(provider: StatusProvider) {
_responseToStatusReblogAction(
provider: provider,
toot: provider.toot()
)
}
static func responseToStatusReblogAction(provider: StatusProvider, cell: UITableViewCell) {
_responseToStatusReblogAction(
provider: provider,
toot: provider.toot(for: cell, indexPath: nil)
)
}
private static func _responseToStatusReblogAction(provider: StatusProvider, toot: Future<Toot?, Never>) {
// prepare authentication
guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else {
assertionFailure()
return
}
// prepare current user infos
guard let _currentMastodonUser = provider.context.authenticationService.activeMastodonAuthentication.value?.user else {
assertionFailure()
return
}
let mastodonUserID = activeMastodonAuthenticationBox.userID
assert(_currentMastodonUser.id == mastodonUserID)
let mastodonUserObjectID = _currentMastodonUser.objectID
guard let context = provider.context else { return }
// haptic feedback generator
let generator = UIImpactFeedbackGenerator(style: .light)
let responseFeedbackGenerator = UIImpactFeedbackGenerator(style: .medium)
toot
.compactMap { toot -> (NSManagedObjectID, Mastodon.API.Reblog.ReblogKind)? in
guard let toot = toot?.reblog ?? toot else { return nil }
let reblogKind: Mastodon.API.Reblog.ReblogKind = {
let isReblogged = toot.rebloggedBy.flatMap { $0.contains(where: { $0.id == mastodonUserID }) } ?? false
return isReblogged ? .undoReblog : .reblog(query: .init(visibility: nil))
}()
return (toot.objectID, reblogKind)
}
.map { tootObjectID, reblogKind -> AnyPublisher<(Toot.ID, Mastodon.API.Reblog.ReblogKind), Error> in
return context.apiService.reblog(
tootObjectID: tootObjectID,
mastodonUserObjectID: mastodonUserObjectID,
reblogKind: reblogKind
)
.map { tootID in (tootID, reblogKind) }
.eraseToAnyPublisher()
}
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
.switchToLatest()
.receive(on: DispatchQueue.main)
.handleEvents { _ in
generator.prepare()
responseFeedbackGenerator.prepare()
} receiveOutput: { _, reblogKind in
generator.impactOccurred()
switch reblogKind {
case .reblog:
os_log("%{public}s[%{public}ld], %{public}s: [Reblog] update local toot reblog status to: %s", ((#file as NSString).lastPathComponent), #line, #function, "reblog")
case .undoReblog:
os_log("%{public}s[%{public}ld], %{public}s: [Reblog] update local toot reblog status to: %s", ((#file as NSString).lastPathComponent), #line, #function, "unreblog")
}
} receiveCompletion: { completion in
switch completion {
case .failure:
// TODO: handle error
break
case .finished:
break
}
}
.map { tootID, reblogKind in
return context.apiService.reblog(
statusID: tootID,
reblogKind: reblogKind,
mastodonAuthenticationBox: activeMastodonAuthenticationBox
)
}
.switchToLatest()
.receive(on: DispatchQueue.main)
.sink { [weak provider] completion in
guard let provider = provider else { return }
if provider.view.window != nil {
responseFeedbackGenerator.impactOccurred()
}
switch completion {
case .failure(let error):
os_log("%{public}s[%{public}ld], %{public}s: [Reblog] remote reblog request fail: %{public}s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
case .finished:
os_log("%{public}s[%{public}ld], %{public}s: [Reblog] remote reblog request success", ((#file as NSString).lastPathComponent), #line, #function)
}
} receiveValue: { response in
// do nothing
}
.store(in: &provider.disposeBag)
}
}
extension StatusProviderFacade {
enum Target {
case toot

View File

@ -0,0 +1,12 @@
//
// TableViewCellHeightCacheableContainer.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-3.
//
import UIKit
protocol TableViewCellHeightCacheableContainer: UIViewController {
// TODO:
}

View File

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

View File

@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "plus.circle.fill.pdf",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,89 @@
%PDF-1.7
1 0 obj
<< >>
endobj
2 0 obj
<< /Length 3 0 R >>
stream
/DeviceRGB CS
/DeviceRGB cs
q
1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm
0.000000 0.000000 0.000000 scn
12.007203 -0.000002 m
18.586674 -0.000002 24.000000 5.413311 24.000000 12.007201 c
24.000000 18.586702 18.586674 24.000000 11.992814 24.000000 c
5.413314 24.000000 0.000000 18.586702 0.000000 12.007201 c
0.000000 5.413311 5.413314 -0.000002 12.007203 -0.000002 c
h
6.478707 12.007201 m
6.478707 12.827837 7.068974 13.432522 7.875220 13.432522 c
10.567494 13.432522 l
10.567494 16.124798 l
10.567494 16.931015 11.172179 17.535698 11.992814 17.535698 c
12.813449 17.535698 13.418134 16.931015 13.418134 16.124798 c
13.418134 13.432522 l
16.110380 13.432522 l
16.931015 13.432522 17.521311 12.827837 17.521311 12.007201 c
17.521311 11.186566 16.931015 10.581882 16.110380 10.581882 c
13.418134 10.581882 l
13.418134 7.889637 l
13.418134 7.083389 12.813449 6.478704 11.992814 6.478704 c
11.172179 6.478704 10.567494 7.083389 10.567494 7.889637 c
10.567494 10.581882 l
7.875220 10.581882 l
7.068974 10.581882 6.478707 11.186566 6.478707 12.007201 c
h
f
n
Q
endstream
endobj
3 0 obj
1071
endobj
4 0 obj
<< /Annots []
/Type /Page
/MediaBox [ 0.000000 0.000000 24.000000 24.000000 ]
/Resources 1 0 R
/Contents 2 0 R
/Parent 5 0 R
>>
endobj
5 0 obj
<< /Kids [ 4 0 R ]
/Count 1
/Type /Pages
>>
endobj
6 0 obj
<< /Type /Catalog
/Pages 5 0 R
>>
endobj
xref
0 7
0000000000 65535 f
0000000010 00000 n
0000000034 00000 n
0000001161 00000 n
0000001184 00000 n
0000001357 00000 n
0000001431 00000 n
trailer
<< /ID [ (some) (id) ]
/Root 6 0 R
/Size 7
>>
startxref
1490
%%EOF

View File

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

View File

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.784",
"green" : "0.682",
"red" : "0.608"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.851",
"green" : "0.565",
"red" : "0.169"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "0.600",
"blue" : "0",
"green" : "0",
"red" : "0"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -5,9 +5,27 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x37",
"green" : "0x2D",
"red" : "0x29"
"blue" : "0xE8",
"green" : "0xE1",
"red" : "0xD9"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.216",
"green" : "0.176",
"red" : "0.161"
}
},
"idiom" : "universal"

View File

@ -5,9 +5,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "232",
"green" : "225",
"red" : "217"
"blue" : "0xE8",
"green" : "0xE1",
"red" : "0xD9"
}
},
"idiom" : "universal"

View File

@ -5,9 +5,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "0.600",
"blue" : "0x43",
"green" : "0x3C",
"red" : "0x3C"
"blue" : "67",
"green" : "60",
"red" : "60"
}
},
"idiom" : "universal"

View File

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

View File

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "147",
"green" : "106",
"red" : "51"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -5,9 +5,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "0.600",
"blue" : "0.263",
"green" : "0.235",
"red" : "0.235"
"blue" : "67",
"green" : "60",
"red" : "60"
}
},
"idiom" : "universal"

View File

@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "plus.circle.fill.pdf",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,101 @@
%PDF-1.7
1 0 obj
<< >>
endobj
2 0 obj
<< /Length 3 0 R >>
stream
/DeviceRGB CS
/DeviceRGB cs
q
1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm
1.000000 1.000000 1.000000 scn
30.000000 15.000000 m
30.000000 6.715729 23.284271 0.000000 15.000000 0.000000 c
6.715729 0.000000 0.000000 6.715729 0.000000 15.000000 c
0.000000 23.284271 6.715729 30.000000 15.000000 30.000000 c
23.284271 30.000000 30.000000 23.284271 30.000000 15.000000 c
h
f
n
Q
q
1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm
0.000000 0.000000 0.000000 scn
15.009004 0.000000 m
23.233341 0.000000 30.000000 6.766640 30.000000 15.009003 c
30.000000 23.233379 23.233341 30.000000 14.991017 30.000000 c
6.766642 30.000000 0.000000 23.233379 0.000000 15.009003 c
0.000000 6.766640 6.766643 0.000000 15.009004 0.000000 c
h
8.098384 15.009003 m
8.098384 16.034798 8.836217 16.790653 9.844025 16.790653 c
13.209368 16.790653 l
13.209368 20.155996 l
13.209368 21.163769 13.965223 21.919624 14.991017 21.919624 c
16.016811 21.919624 16.772667 21.163769 16.772667 20.155996 c
16.772667 16.790653 l
20.137974 16.790653 l
21.163769 16.790653 21.901638 16.034798 21.901638 15.009003 c
21.901638 13.983208 21.163769 13.227352 20.137974 13.227352 c
16.772667 13.227352 l
16.772667 9.862047 l
16.772667 8.854239 16.016811 8.098381 14.991017 8.098381 c
13.965223 8.098381 13.209368 8.854239 13.209368 9.862047 c
13.209368 13.227352 l
9.844025 13.227352 l
8.836217 13.227352 8.098384 13.983208 8.098384 15.009003 c
h
f
n
Q
endstream
endobj
3 0 obj
1426
endobj
4 0 obj
<< /Annots []
/Type /Page
/MediaBox [ 0.000000 0.000000 30.000000 30.000000 ]
/Resources 1 0 R
/Contents 2 0 R
/Parent 5 0 R
>>
endobj
5 0 obj
<< /Kids [ 4 0 R ]
/Count 1
/Type /Pages
>>
endobj
6 0 obj
<< /Type /Catalog
/Pages 5 0 R
>>
endobj
xref
0 7
0000000000 65535 f
0000000010 00000 n
0000000034 00000 n
0000001516 00000 n
0000001539 00000 n
0000001712 00000 n
0000001786 00000 n
trailer
<< /ID [ (some) (id) ]
/Root 6 0 R
/Size 7
>>
startxref
1845
%%EOF

View File

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.604",
"green" : "0.741",
"red" : "0.475"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -1,5 +1,9 @@
"Common.Alerts.Common.PleaseTryAgain" = "Please try again.";
"Common.Alerts.Common.PleaseTryAgainLater" = "Please try again later.";
"Common.Alerts.ServerError.Title" = "Server Error";
"Common.Alerts.SignUpFailure.Title" = "Sign Up Failure";
"Common.Alerts.VoteFailure.PollExpired" = "The poll has expired";
"Common.Alerts.VoteFailure.Title" = "Vote Failure";
"Common.Controls.Actions.Add" = "Add";
"Common.Controls.Actions.Back" = "Back";
"Common.Controls.Actions.Cancel" = "Cancel";
@ -17,32 +21,19 @@
"Common.Controls.Actions.SignUp" = "Sign Up";
"Common.Controls.Actions.TakePhoto" = "Take photo";
"Common.Controls.Status.MediaContentWarning" = "Tap to reveal that may be sensitive";
"Common.Controls.Status.Poll.Closed" = "Closed";
"Common.Controls.Status.Poll.TimeLeft" = "%@ left";
"Common.Controls.Status.Poll.Vote" = "Vote";
"Common.Controls.Status.Poll.VoteCount.Multiple" = "%d votes";
"Common.Controls.Status.Poll.VoteCount.Single" = "%d vote";
"Common.Controls.Status.Poll.VoterCount.Multiple" = "%d voters";
"Common.Controls.Status.Poll.VoterCount.Single" = "%d voter";
"Common.Controls.Status.ShowPost" = "Show Post";
"Common.Controls.Status.StatusContentWarning" = "content warning";
"Common.Controls.Status.UserBoosted" = "%@ boosted";
"Common.Controls.Status.UserReblogged" = "%@ reblogged";
"Common.Controls.Timeline.LoadMore" = "Load More";
"Common.Countable.Photo.Multiple" = "photos";
"Common.Countable.Photo.Single" = "photo";
"Common.Errors.ErrAccepted" = "must be accepted";
"Common.Errors.ErrBlank" = "is required";
"Common.Errors.ErrBlocked" = "contains a disallowed e-mail provider";
"Common.Errors.ErrInclusion" = "is not a supported value";
"Common.Errors.ErrInvalid" = "is invalid";
"Common.Errors.ErrReserved" = "is a reserved keyword";
"Common.Errors.ErrTaken" = "is already in use";
"Common.Errors.ErrTooLong" = "is too long";
"Common.Errors.ErrTooShort" = "is too short";
"Common.Errors.ErrUnreachable" = "does not seem to exist";
"Common.Errors.Item.Agreement" = "agreement";
"Common.Errors.Item.Email" = "email";
"Common.Errors.Item.Locale" = "locale";
"Common.Errors.Item.Password" = "password";
"Common.Errors.Item.Reason" = "reason";
"Common.Errors.Item.Username" = "username";
"Common.Errors.Itemdetail.EmailInvalid" = "This is not a valid e-mail address";
"Common.Errors.Itemdetail.PasswordTooShrot" = "password is too short (must be at least 8 characters)";
"Common.Errors.Itemdetail.UsernameInvalid" = "Username must only contain alphanumeric characters and underscores";
"Common.Errors.Itemdetail.UsernameTooLong" = "username is too long ( can't be longer than 30 characters)";
"Scene.ConfirmEmail.Button.DontReceiveEmail" = "I never got an email";
"Scene.ConfirmEmail.Button.OpenEmailApp" = "Open Email App";
"Scene.ConfirmEmail.DontReceiveEmail.Description" = "Check if your email address is correct as well as your junk folder if you havent.";
@ -57,20 +48,39 @@ tap the link to confirm your account.";
"Scene.ConfirmEmail.Title" = "One last thing.";
"Scene.HomeTimeline.Title" = "Home";
"Scene.PublicTimeline.Title" = "Public";
"Scene.Register.CheckEmail" = "Regsiter request sent. Please check your email.";
"Scene.Register.Error.Item.Agreement" = "Agreement";
"Scene.Register.Error.Item.Email" = "Email";
"Scene.Register.Error.Item.Locale" = "Locale";
"Scene.Register.Error.Item.Password" = "Password";
"Scene.Register.Error.Item.Reason" = "Reason";
"Scene.Register.Error.Item.Username" = "Username";
"Scene.Register.Error.Reason.Accepted" = "%@ must be accepted";
"Scene.Register.Error.Reason.Blank" = "%@ is required";
"Scene.Register.Error.Reason.Blocked" = "%@ contains a disallowed e-mail provider";
"Scene.Register.Error.Reason.Inclusion" = "%@ is not a supported value";
"Scene.Register.Error.Reason.Invalid" = "%@ is invalid";
"Scene.Register.Error.Reason.Reserved" = "%@ is a reserved keyword";
"Scene.Register.Error.Reason.Taken" = "%@ is already in use";
"Scene.Register.Error.Reason.TooLong" = "%@ is too long";
"Scene.Register.Error.Reason.TooShort" = "%@ is too short";
"Scene.Register.Error.Reason.Unreachable" = "%@ does not seem to exist";
"Scene.Register.Error.Special.EmailInvalid" = "This is not a valid e-mail address";
"Scene.Register.Error.Special.PasswordTooShort" = "Password is too short (must be at least 8 characters)";
"Scene.Register.Error.Special.UsernameInvalid" = "Username must only contain alphanumeric characters and underscores";
"Scene.Register.Error.Special.UsernameTooLong" = "Username is too long (can't be longer than 30 characters)";
"Scene.Register.Input.DisplayName.Placeholder" = "display name";
"Scene.Register.Input.Email.Placeholder" = "email";
"Scene.Register.Input.Invite.RegistrationUserInviteRequest" = "Why do you want to join?";
"Scene.Register.Input.Password.Hint" = "Your password needs at least eight characters";
"Scene.Register.Input.Password.Placeholder" = "password";
"Scene.Register.Input.Password.Prompt" = "Your password needs at least:";
"Scene.Register.Input.Password.PromptEightCharacters" = "Eight characters";
"Scene.Register.Input.Username.DuplicatePrompt" = "This username is taken.";
"Scene.Register.Input.Username.Placeholder" = "username";
"Scene.Register.Success" = "Success";
"Scene.Register.Title" = "Tell us about you.";
"Scene.ServerPicker.Button.Category.All" = "All";
"Scene.ServerPicker.Button.Seeless" = "See Less";
"Scene.ServerPicker.Button.Seemore" = "See More";
"Scene.ServerPicker.EmptyState.BadNetwork" = "Something went wrong while loading data. Check your internet connection.";
"Scene.ServerPicker.EmptyState.FindingServers" = "Finding available servers...";
"Scene.ServerPicker.Input.Placeholder" = "Find a server or join your own...";
"Scene.ServerPicker.Label.Category" = "CATEGORY";
"Scene.ServerPicker.Label.Language" = "LANGUAGE";
@ -82,4 +92,4 @@ any server.";
"Scene.ServerRules.Subtitle" = "These rules are set by the admins of %@.";
"Scene.ServerRules.Title" = "Some ground rules.";
"Scene.Welcome.Slogan" = "Social networking
back in your hands.";
back in your hands.";

View File

@ -7,6 +7,8 @@
import os.log
import UIKit
import CoreData
import CoreDataStack
#if DEBUG
extension HomeTimelineViewController {
@ -17,6 +19,8 @@ extension HomeTimelineViewController {
identifier: nil,
options: .displayInline,
children: [
moveMenu,
dropMenu,
UIAction(title: "Show Public Timeline", image: UIImage(systemName: "list.dash"), attributes: []) { [weak self] action in
guard let self = self else { return }
self.showPublicTimelineAction(action)
@ -29,10 +33,185 @@ extension HomeTimelineViewController {
)
return menu
}
var moveMenu: UIMenu {
return UIMenu(
title: "Move to…",
image: UIImage(systemName: "arrow.forward.circle"),
identifier: nil,
options: [],
children: [
UIAction(title: "First Gap", image: nil, attributes: [], handler: { [weak self] action in
guard let self = self else { return }
self.moveToTopGapAction(action)
}),
UIAction(title: "First Reblog Toot", image: nil, attributes: [], handler: { [weak self] action in
guard let self = self else { return }
self.moveToFirstReblogToot(action)
}),
UIAction(title: "First Poll Toot", image: nil, attributes: [], handler: { [weak self] action in
guard let self = self else { return }
self.moveToFirstPollToot(action)
}),
UIAction(title: "First Audio Toot", image: nil, attributes: [], handler: { [weak self] action in
guard let self = self else { return }
self.moveToFirstAudioToot(action)
}),
// UIAction(title: "First Reply Toot", image: nil, attributes: [], handler: { [weak self] action in
// guard let self = self else { return }
// self.moveToFirstReplyToot(action)
// }),
// UIAction(title: "First Reply Reblog", image: nil, attributes: [], handler: { [weak self] action in
// guard let self = self else { return }
// self.moveToFirstReplyReblog(action)
// }),
// UIAction(title: "First Video Toot", image: nil, attributes: [], handler: { [weak self] action in
// guard let self = self else { return }
// self.moveToFirstVideoToot(action)
// }),
// UIAction(title: "First GIF Toot", image: nil, attributes: [], handler: { [weak self] action in
// guard let self = self else { return }
// self.moveToFirstGIFToot(action)
// }),
]
)
}
var dropMenu: UIMenu {
return UIMenu(
title: "Drop…",
image: UIImage(systemName: "minus.circle"),
identifier: nil,
options: [],
children: [50, 100, 150, 200, 250, 300].map { count in
UIAction(title: "Drop Recent \(count) Toots", image: nil, attributes: [], handler: { [weak self] action in
guard let self = self else { return }
self.dropRecentTootsAction(action, count: count)
})
}
)
}
}
extension HomeTimelineViewController {
@objc private func moveToTopGapAction(_ sender: UIAction) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
let snapshotTransitioning = diffableDataSource.snapshot()
let item = snapshotTransitioning.itemIdentifiers.first(where: { item in
switch item {
case .homeMiddleLoader: return true
default: return false
}
})
if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) {
tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true)
}
}
@objc private func moveToFirstReblogToot(_ sender: UIAction) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
let snapshotTransitioning = diffableDataSource.snapshot()
let item = snapshotTransitioning.itemIdentifiers.first(where: { item in
switch item {
case .homeTimelineIndex(let objectID, _):
let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex
return homeTimelineIndex.toot.reblog != nil
default:
return false
}
})
if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) {
tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true)
tableView.blinkRow(at: IndexPath(row: index, section: 0))
} else {
print("Not found reblog toot")
}
}
@objc private func moveToFirstPollToot(_ sender: UIAction) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
let snapshotTransitioning = diffableDataSource.snapshot()
let item = snapshotTransitioning.itemIdentifiers.first(where: { item in
switch item {
case .homeTimelineIndex(let objectID, _):
let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex
let toot = homeTimelineIndex.toot.reblog ?? homeTimelineIndex.toot
return toot.poll != nil
default:
return false
}
})
if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) {
tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true)
tableView.blinkRow(at: IndexPath(row: index, section: 0))
} else {
print("Not found poll toot")
}
}
@objc private func moveToFirstAudioToot(_ sender: UIAction) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
let snapshotTransitioning = diffableDataSource.snapshot()
let item = snapshotTransitioning.itemIdentifiers.first(where: { item in
switch item {
case .homeTimelineIndex(let objectID, _):
let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex
let toot = homeTimelineIndex.toot.reblog ?? homeTimelineIndex.toot
return toot.mediaAttachments?.contains(where: { $0.type == .audio }) ?? false
default:
return false
}
})
if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) {
tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true)
tableView.blinkRow(at: IndexPath(row: index, section: 0))
} else {
print("Not found audio toot")
}
}
@objc private func dropRecentTootsAction(_ sender: UIAction, count: Int) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
let snapshotTransitioning = diffableDataSource.snapshot()
let droppingObjectIDs = snapshotTransitioning.itemIdentifiers.prefix(count).compactMap { item -> NSManagedObjectID? in
switch item {
case .homeTimelineIndex(let objectID, _): return objectID
default: return nil
}
}
var droppingTootObjectIDs: [NSManagedObjectID] = []
context.apiService.backgroundManagedObjectContext.performChanges { [weak self] in
guard let self = self else { return }
for objectID in droppingObjectIDs {
guard let homeTimelineIndex = try? self.context.apiService.backgroundManagedObjectContext.existingObject(with: objectID) as? HomeTimelineIndex else { continue }
droppingTootObjectIDs.append(homeTimelineIndex.toot.objectID)
self.context.apiService.backgroundManagedObjectContext.delete(homeTimelineIndex)
}
}
.sink { [weak self] result in
guard let self = self else { return }
switch result {
case .success:
self.context.apiService.backgroundManagedObjectContext.performChanges { [weak self] in
guard let self = self else { return }
for objectID in droppingTootObjectIDs {
guard let toot = try? self.context.apiService.backgroundManagedObjectContext.existingObject(with: objectID) as? Toot else { continue }
self.context.apiService.backgroundManagedObjectContext.delete(toot)
}
}
.sink { _ in
// do nothing
}
.store(in: &self.disposeBag)
case .failure(let error):
assertionFailure(error.localizedDescription)
}
}
.store(in: &disposeBag)
}
@objc private func showPublicTimelineAction(_ sender: UIAction) {
coordinator.present(scene: .publicTimeline, from: self, transition: .show)
}

View File

@ -8,6 +8,7 @@
import os.log
import UIKit
import Combine
import CoreData
import CoreDataStack
// MARK: - StatusProvider
@ -47,25 +48,26 @@ extension HomeTimelineViewController: StatusProvider {
return Future { promise in promise(.success(nil)) }
}
var managedObjectContext: NSManagedObjectContext {
return viewModel.fetchedResultsController.managedObjectContext
}
var tableViewDiffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? {
return viewModel.diffableDataSource
}
func item(for cell: UITableViewCell, indexPath: IndexPath?) -> Future<Item?, Never> {
return Future { promise in
guard let diffableDataSource = self.viewModel.diffableDataSource else {
assertionFailure()
promise(.success(nil))
return
}
guard let indexPath = indexPath ?? self.tableView.indexPath(for: cell),
let item = diffableDataSource.itemIdentifier(for: indexPath) else {
promise(.success(nil))
return
}
promise(.success(item))
func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? {
guard let diffableDataSource = self.viewModel.diffableDataSource else {
assertionFailure()
return nil
}
guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }),
let item = diffableDataSource.itemIdentifier(for: indexPath) else {
return nil
}
return item
}
}

View File

@ -106,7 +106,7 @@ extension HomeTimelineViewController {
viewModel.setupDiffableDataSource(
for: tableView,
dependency: self,
timelinePostTableViewCellDelegate: self,
statusTableViewCellDelegate: self,
timelineMiddleLoaderTableViewCellDelegate: self
)
@ -141,7 +141,8 @@ extension HomeTimelineViewController {
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
context.videoPlaybackService.viewDidDisappear(from: self)
context.audioPlaybackService.viewDidDisappear(from: self)
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
@ -220,18 +221,26 @@ extension HomeTimelineViewController: LoadMoreConfigurableTableViewContainer {
// MARK: - UITableViewDelegate
extension HomeTimelineViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
guard let diffableDataSource = viewModel.diffableDataSource else { return 100 }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 }
guard let frame = viewModel.cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else {
return 200
}
// os_log("%{public}s[%{public}ld], %{public}s: cache cell frame %s", ((#file as NSString).lastPathComponent), #line, #function, frame.debugDescription)
return ceil(frame.height)
// TODO:
// func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
// guard let diffableDataSource = viewModel.diffableDataSource else { return 100 }
// guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 }
//
// guard let frame = viewModel.cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else {
// return 200
// }
// // os_log("%{public}s[%{public}ld], %{public}s: cache cell frame %s", ((#file as NSString).lastPathComponent), #line, #function, frame.debugDescription)
//
// return ceil(frame.height)
// }
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
handleTableView(tableView, willDisplay: cell, forRowAt: indexPath)
}
func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
}
}
// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate
@ -332,5 +341,21 @@ extension HomeTimelineViewController: ScrollViewContainer {
}
// MARK: - AVPlayerViewControllerDelegate
extension HomeTimelineViewController: AVPlayerViewControllerDelegate {
func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator)
}
func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator)
}
}
// MARK: - StatusTableViewCellDelegate
extension HomeTimelineViewController: StatusTableViewCellDelegate { }
extension HomeTimelineViewController: StatusTableViewCellDelegate {
weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self }
func parent() -> UIViewController { return self }
}

View File

@ -15,7 +15,7 @@ extension HomeTimelineViewModel {
func setupDiffableDataSource(
for tableView: UITableView,
dependency: NeedsDependency,
timelinePostTableViewCellDelegate: StatusTableViewCellDelegate,
statusTableViewCellDelegate: StatusTableViewCellDelegate,
timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate
) {
let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common)
@ -28,7 +28,7 @@ extension HomeTimelineViewModel {
dependency: dependency,
managedObjectContext: fetchedResultsController.managedObjectContext,
timestampUpdatePublisher: timestampUpdatePublisher,
timelinePostTableViewCellDelegate: timelinePostTableViewCellDelegate,
statusTableViewCellDelegate: statusTableViewCellDelegate,
timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate
)
}
@ -73,7 +73,7 @@ extension HomeTimelineViewModel: NSFetchedResultsControllerDelegate {
// that's will be the most fastest fetch because of upstream just update and no modify needs consider
var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusTimelineAttribute] = [:]
var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:]
for item in oldSnapshot.itemIdentifiers {
guard case let .homeTimelineIndex(objectID, attribute) = item else { continue }
@ -88,7 +88,7 @@ extension HomeTimelineViewModel: NSFetchedResultsControllerDelegate {
guard let spoilerText = toot.spoilerText, !spoilerText.isEmpty else { return false }
return true
}()
let attribute = oldSnapshotAttributeDict[timelineIndex.objectID] ?? Item.StatusTimelineAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: toot.sensitive)
let attribute = oldSnapshotAttributeDict[timelineIndex.objectID] ?? Item.StatusAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: toot.sensitive)
// append new item into snapshot
newTimelineItems.append(.homeTimelineIndex(objectID: timelineIndex.objectID, attribute: attribute))

View File

@ -110,10 +110,10 @@ final class HomeTimelineViewModel: NSObject {
context.authenticationService.activeMastodonAuthentication
.sink { [weak self] activeMastodonAuthentication in
guard let self = self else { return }
guard let twitterAuthentication = activeMastodonAuthentication else { return }
let activeTwitterUserID = twitterAuthentication.userID
guard let mastodonAuthentication = activeMastodonAuthentication else { return }
let activeMastodonUserID = mastodonAuthentication.userID
let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
HomeTimelineIndex.predicate(userID: activeTwitterUserID),
HomeTimelineIndex.predicate(userID: activeMastodonUserID),
HomeTimelineIndex.notDeleted()
])
self.timelinePredicate.value = predicate

View File

@ -111,7 +111,24 @@ extension MastodonConfirmEmailViewController {
case .failure(let error):
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: swap user access token swap fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
case .finished:
break
// upload avatar and set display name in the background
self.context.apiService.accountUpdateCredentials(
domain: self.viewModel.authenticateInfo.domain,
query: self.viewModel.updateCredentialQuery,
authorization: Mastodon.API.OAuth.Authorization(accessToken: self.viewModel.userToken.accessToken)
)
.retry(3)
.sink { completion in
switch completion {
case .failure(let error):
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: setup avatar & display name fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
case .finished:
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: setup avatar & display name success", ((#file as NSString).lastPathComponent), #line, #function)
}
} receiveValue: { _ in
// do nothing
}
.store(in: &self.context.disposeBag) // execute in the background
}
} receiveValue: { response in
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: user %s's email confirmed", ((#file as NSString).lastPathComponent), #line, #function, response.value.username)

View File

@ -12,20 +12,29 @@ import MastodonSDK
final class MastodonConfirmEmailViewModel {
var disposeBag = Set<AnyCancellable>()
// input
let context: AppContext
var email: String
let authenticateInfo: AuthenticationViewModel.AuthenticateInfo
let userToken: Mastodon.Entity.Token
let updateCredentialQuery: Mastodon.API.Account.UpdateCredentialQuery
let timestampUpdatePublisher = Timer.publish(every: 4.0, on: .main, in: .common)
.autoconnect()
.share()
.eraseToAnyPublisher()
init(context: AppContext, email: String, authenticateInfo: AuthenticationViewModel.AuthenticateInfo, userToken: Mastodon.Entity.Token) {
init(
context: AppContext,
email: String,
authenticateInfo: AuthenticationViewModel.AuthenticateInfo,
userToken: Mastodon.Entity.Token,
updateCredentialQuery: Mastodon.API.Account.UpdateCredentialQuery
) {
self.context = context
self.email = email
self.authenticateInfo = authenticateInfo
self.userToken = userToken
self.updateCredentialQuery = updateCredentialQuery
}
}

View File

@ -9,11 +9,7 @@ import UIKit
class PickServerCategoryCollectionViewCell: UICollectionViewCell {
var category: MastodonPickServerViewModel.Category? {
didSet {
categoryView.category = category
}
}
var observations = Set<NSKeyValueObservation>()
var categoryView: PickServerCategoryView = {
let view = PickServerCategoryView()
@ -21,10 +17,9 @@ class PickServerCategoryCollectionViewCell: UICollectionViewCell {
return view
}()
override var isSelected: Bool {
didSet {
categoryView.selected = isSelected
}
override func prepareForReuse() {
super.prepareForReuse()
observations.removeAll()
}
override init(frame: CGRect) {

View File

@ -0,0 +1,12 @@
//
// MastodonPickServerAppearance.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021/3/6.
//
import UIKit
enum MastodonPickServerAppearance {
static let tableViewCornerRadius: CGFloat = 10
}

View File

@ -5,31 +5,26 @@
// Created by BradGao on 2021/2/20.
//
import os.log
import UIKit
import Combine
import OSLog
import MastodonSDK
final class MastodonPickServerViewController: UIViewController, NeedsDependency {
private var disposeBag = Set<AnyCancellable>()
private var tableViewObservation: NSKeyValueObservation?
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
var viewModel: MastodonPickServerViewModel!
private var isAuthenticating = CurrentValueSubject<Bool, Never>(false)
private var expandServerDomainSet = Set<String>()
enum Section: CaseIterable {
case title
case categories
case search
case serverList
}
let emptyStateView = PickServerEmptyStateView()
let tableViewTopPaddingView = UIView() // fix empty state view background display when tableView bounce scrolling
var tableViewTopPaddingViewHeightLayoutConstraint: NSLayoutConstraint!
let tableView: UITableView = {
let tableView = ControlContainableTableView()
tableView.register(PickServerTitleCell.self, forCellReuseIdentifier: String(describing: PickServerTitleCell.self))
@ -53,6 +48,7 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency
}()
deinit {
tableViewObservation = nil
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
@ -60,10 +56,6 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency
extension MastodonPickServerViewController {
override var preferredStatusBarStyle: UIStatusBarStyle {
return .darkContent
}
override func viewDidLoad() {
super.viewDidLoad()
@ -78,12 +70,44 @@ extension MastodonPickServerViewController {
view.layoutMarginsGuide.bottomAnchor.constraint(equalTo: nextStepButton.bottomAnchor, constant: WelcomeViewController.viewBottomPaddingHeight),
])
emptyStateView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(emptyStateView)
NSLayoutConstraint.activate([
emptyStateView.topAnchor.constraint(equalTo: view.topAnchor),
emptyStateView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor),
emptyStateView.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor),
nextStepButton.topAnchor.constraint(equalTo: emptyStateView.bottomAnchor, constant: 21),
])
// fix AutoLayout warning when observe before view appear
viewModel.viewWillAppear
.receive(on: DispatchQueue.main)
.sink { [weak self] in
guard let self = self else { return }
self.tableViewObservation = self.tableView.observe(\.contentSize, options: [.initial, .new]) { [weak self] tableView, _ in
guard let self = self else { return }
self.updateEmptyStateViewLayout()
}
}
.store(in: &disposeBag)
tableViewTopPaddingView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(tableViewTopPaddingView)
tableViewTopPaddingViewHeightLayoutConstraint = tableViewTopPaddingView.heightAnchor.constraint(equalToConstant: 0.0).priority(.defaultHigh)
NSLayoutConstraint.activate([
tableViewTopPaddingView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor),
tableViewTopPaddingView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableViewTopPaddingView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableViewTopPaddingViewHeightLayoutConstraint,
])
tableViewTopPaddingView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
view.addSubview(tableView)
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: view.topAnchor),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
nextStepButton.topAnchor.constraint(equalTo: tableView.bottomAnchor, constant: 7)
nextStepButton.topAnchor.constraint(equalTo: tableView.bottomAnchor, constant: 7),
])
switch viewModel.mode {
@ -95,31 +119,17 @@ extension MastodonPickServerViewController {
nextStepButton.addTarget(self, action: #selector(nextStepButtonDidClicked(_:)), for: .touchUpInside)
tableView.delegate = self
tableView.dataSource = self
viewModel
.searchedServers
.receive(on: DispatchQueue.main)
.sink { _ in
} receiveValue: { [weak self] servers in
self?.tableView.beginUpdates()
self?.tableView.reloadSections(IndexSet(integer: 3), with: .automatic)
self?.tableView.endUpdates()
if let selectedServer = self?.viewModel.selectedServer.value, servers.contains(selectedServer) {
// Previously selected server is still in the list, do nothing
} else {
// Previously selected server is not in the updated list, reset the selectedServer's value
self?.viewModel.selectedServer.send(nil)
}
}
.store(in: &disposeBag)
viewModel.setupDiffableDataSource(
for: tableView,
dependency: self,
pickServerCategoriesCellDelegate: self,
pickServerSearchCellDelegate: self,
pickServerCellDelegate: self
)
viewModel
.selectedServer
.map {
$0 != nil
}
.map { $0 != nil }
.assign(to: \.isEnabled, on: nextStepButton)
.store(in: &disposeBag)
@ -158,7 +168,7 @@ extension MastodonPickServerViewController {
}
.store(in: &disposeBag)
isAuthenticating
viewModel.isAuthenticating
.receive(on: DispatchQueue.main)
.sink { [weak self] isAuthenticating in
guard let self = self else { return }
@ -166,9 +176,42 @@ extension MastodonPickServerViewController {
}
.store(in: &disposeBag)
viewModel.fetchAllServers()
viewModel.emptyStateViewState
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
guard let self = self else { return }
switch state {
case .none:
self.emptyStateView.isHidden = true
case .loading:
self.emptyStateView.isHidden = false
self.emptyStateView.networkIndicatorImageView.isHidden = true
self.emptyStateView.activityIndicatorView.startAnimating()
self.emptyStateView.infoLabel.isHidden = false
self.emptyStateView.infoLabel.text = L10n.Scene.ServerPicker.EmptyState.findingServers
self.emptyStateView.infoLabel.textAlignment = self.traitCollection.layoutDirection == .rightToLeft ? .right : .left
case .badNetwork:
self.emptyStateView.isHidden = false
self.emptyStateView.networkIndicatorImageView.isHidden = false
self.emptyStateView.activityIndicatorView.stopAnimating()
self.emptyStateView.infoLabel.isHidden = false
self.emptyStateView.infoLabel.text = L10n.Scene.ServerPicker.EmptyState.badNetwork
self.emptyStateView.infoLabel.textAlignment = .center
}
}
.store(in: &disposeBag)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
viewModel.viewWillAppear.send()
}
}
extension MastodonPickServerViewController {
@objc
private func nextStepButtonDidClicked(_ sender: UIButton) {
switch viewModel.mode {
@ -181,7 +224,7 @@ extension MastodonPickServerViewController {
private func doSignIn() {
guard let server = viewModel.selectedServer.value else { return }
isAuthenticating.send(true)
viewModel.isAuthenticating.send(true)
context.apiService.createApplication(domain: server.domain)
.tryMap { response -> MastodonPickServerViewModel.AuthenticateInfo in
let application = response.value
@ -193,7 +236,7 @@ extension MastodonPickServerViewController {
.receive(on: DispatchQueue.main)
.sink { [weak self] completion in
guard let self = self else { return }
self.isAuthenticating.send(false)
self.viewModel.isAuthenticating.send(false)
switch completion {
case .failure(let error):
@ -221,7 +264,7 @@ extension MastodonPickServerViewController {
private func doSignUp() {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
guard let server = viewModel.selectedServer.value else { return }
isAuthenticating.send(true)
viewModel.isAuthenticating.send(true)
context.apiService.instance(domain: server.domain)
.compactMap { [weak self] response -> AnyPublisher<MastodonPickServerViewModel.SignUpResponseFirst, Error>? in
@ -257,7 +300,7 @@ extension MastodonPickServerViewController {
.receive(on: DispatchQueue.main)
.sink { [weak self] completion in
guard let self = self else { return }
self.isAuthenticating.send(false)
self.viewModel.isAuthenticating.send(false)
switch completion {
case .failure(let error):
@ -291,141 +334,136 @@ extension MastodonPickServerViewController {
}
}
// MARK: - UITableViewDelegate
extension MastodonPickServerViewController: UITableViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard scrollView === tableView else { return }
let offsetY = scrollView.contentOffset.y + scrollView.safeAreaInsets.top
if offsetY < 0 {
tableViewTopPaddingViewHeightLayoutConstraint.constant = abs(offsetY)
} else {
tableViewTopPaddingViewHeightLayoutConstraint.constant = 0
}
}
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
let headerView = UIView()
headerView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
return headerView
}
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
let category = Section.allCases[section]
switch category {
case .title:
guard let diffableDataSource = viewModel.diffableDataSource else { return 0 }
let sections = diffableDataSource.snapshot().sectionIdentifiers
let section = sections[section]
switch section {
case .header:
return 20
case .categories:
case .category:
// Since category view has a blur shadow effect, its height need to be large than the actual height,
// Thus we reduce the section header's height by 10, and make the category cell height 60+20(10 inset for top and bottom)
return 10
case .search:
// Same reason as above
return 10
case .serverList:
case .servers:
return 0
}
}
func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
guard let diffableDataSource = viewModel.diffableDataSource else { return nil }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return nil }
guard case let .server(server) = item else { return nil }
if tableView.indexPathForSelectedRow == indexPath {
tableView.deselectRow(at: indexPath, animated: false)
viewModel.selectedServer.send(nil)
return nil
}
return indexPath
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
guard case let .server(server, _) = item else { return }
tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none)
viewModel.selectedServer.send(viewModel.searchedServers.value[indexPath.row])
viewModel.selectedServer.send(server)
}
func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: false)
viewModel.selectedServer.send(nil)
}
}
extension MastodonPickServerViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
return UIView()
}
func numberOfSections(in tableView: UITableView) -> Int {
return Self.Section.allCases.count
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
let section = Self.Section.allCases[section]
switch section {
case .title,
.categories,
.search:
return 1
case .serverList:
return viewModel.searchedServers.value.count
}
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
let section = Self.Section.allCases[indexPath.section]
switch section {
case .title:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerTitleCell.self), for: indexPath) as! PickServerTitleCell
return cell
case .categories:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerCategoriesCell.self), for: indexPath) as! PickServerCategoriesCell
cell.dataSource = self
cell.delegate = self
return cell
switch item {
case .categoryPicker:
guard let cell = cell as? PickServerCategoriesCell else { return }
guard let diffableDataSource = cell.diffableDataSource else { return }
let snapshot = diffableDataSource.snapshot()
let item = viewModel.selectCategoryItem.value
guard let section = snapshot.indexOfSection(.main),
let row = snapshot.indexOfItem(item) else { return }
cell.collectionView.selectItem(at: IndexPath(item: row, section: section), animated: false, scrollPosition: .centeredHorizontally)
case .search:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerSearchCell.self), for: indexPath) as! PickServerSearchCell
cell.delegate = self
return cell
case .serverList:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerCell.self), for: indexPath) as! PickServerCell
let server = viewModel.searchedServers.value[indexPath.row]
cell.server = server
if expandServerDomainSet.contains(server.domain) {
cell.mode = .expand
} else {
cell.mode = .collapse
}
if server == viewModel.selectedServer.value {
tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none)
} else {
tableView.deselectRow(at: indexPath, animated: false)
}
cell.delegate = self
return cell
guard let cell = cell as? PickServerSearchCell else { return }
cell.searchTextField.text = viewModel.searchText.value
default:
break
}
}
}
extension MastodonPickServerViewController {
private func updateEmptyStateViewLayout() {
guard let diffableDataSource = self.viewModel.diffableDataSource else { return }
guard let indexPath = diffableDataSource.indexPath(for: .search) else { return }
let rectInTableView = tableView.rectForRow(at: indexPath)
emptyStateView.topPaddingViewTopLayoutConstraint.constant = rectInTableView.maxY
}
}
// MARK: - PickServerCategoriesCellDelegate
extension MastodonPickServerViewController: PickServerCategoriesCellDelegate {
func pickServerCategoriesCell(_ cell: PickServerCategoriesCell, collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let diffableDataSource = cell.diffableDataSource else { return }
let item = diffableDataSource.itemIdentifier(for: indexPath)
viewModel.selectCategoryItem.value = item ?? .all
}
}
// MARK: - PickServerSearchCellDelegate
extension MastodonPickServerViewController: PickServerSearchCellDelegate {
func pickServerSearchCell(_ cell: PickServerSearchCell, searchTextDidChange searchText: String?) {
viewModel.searchText.send(searchText ?? "")
}
}
// MARK: - PickServerCellDelegate
extension MastodonPickServerViewController: PickServerCellDelegate {
func pickServerCell(modeChange server: Mastodon.Entity.Server, newMode: PickServerCell.Mode, updates: (() -> Void)) {
if newMode == .collapse {
expandServerDomainSet.remove(server.domain)
} else {
expandServerDomainSet.insert(server.domain)
}
func pickServerCell(_ cell: PickServerCell, expandButtonPressed button: UIButton) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
guard let indexPath = tableView.indexPath(for: cell) else { return }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
guard case let .server(_, attribute) = item else { return }
attribute.isExpand.toggle()
tableView.beginUpdates()
updates()
cell.updateExpandMode(mode: attribute.isExpand ? .expand : .collapse)
tableView.endUpdates()
if newMode == .expand, let modeChangeIndex = self.viewModel.searchedServers.value.firstIndex(where: { $0 == server }), self.tableView.indexPathsForVisibleRows?.last?.row == modeChangeIndex {
self.tableView.scrollToRow(at: IndexPath(row: modeChangeIndex, section: 3), at: .bottom, animated: true)
}
}
}
extension MastodonPickServerViewController: PickServerSearchCellDelegate {
func pickServerSearchCell(didChange searchText: String?) {
viewModel.searchText.send(searchText)
}
}
extension MastodonPickServerViewController: PickServerCategoriesDataSource, PickServerCategoriesDelegate {
func numberOfCategories() -> Int {
return viewModel.categories.count
}
func category(at index: Int) -> MastodonPickServerViewModel.Category {
return viewModel.categories[index]
}
func selectedIndex() -> Int {
return viewModel.selectCategoryIndex.value
}
func pickServerCategoriesCell(didSelect index: Int) {
return viewModel.selectCategoryIndex.send(index)
// expand attribute change do not needs apply snapshot to diffable data source
// but should I block the viewModel data binding during tableView.beginUpdates/endUpdates?
}
}

View File

@ -0,0 +1,39 @@
//
// MastodonPickServerViewController+Diffable.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021/3/5.
//
import UIKit
extension MastodonPickServerViewModel {
func setupDiffableDataSource(
for tableView: UITableView,
dependency: NeedsDependency,
pickServerCategoriesCellDelegate: PickServerCategoriesCellDelegate,
pickServerSearchCellDelegate: PickServerSearchCellDelegate,
pickServerCellDelegate: PickServerCellDelegate
) {
diffableDataSource = PickServerSection.tableViewDiffableDataSource(
for: tableView,
dependency: dependency,
pickServerCategoriesCellDelegate: pickServerCategoriesCellDelegate,
pickServerSearchCellDelegate: pickServerSearchCellDelegate,
pickServerCellDelegate: pickServerCellDelegate
)
var snapshot = NSDiffableDataSourceSnapshot<PickServerSection, PickServerItem>()
snapshot.appendSections([.header, .category, .search, .servers])
snapshot.appendItems([.header], toSection: .header)
snapshot.appendItems([.categoryPicker(items: categoryPickerItems)], toSection: .category)
snapshot.appendItems([.search], toSection: .search)
diffableDataSource?.apply(snapshot, animatingDifferences: false, completion: nil)
loadIndexedServerStateMachine.enter(LoadIndexedServerState.Loading.self)
}
}

View File

@ -0,0 +1,92 @@
//
// MastodonPickServerViewModel+LoadIndexedServerState.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021/3/5.
//
import os.log
import Foundation
import GameplayKit
import MastodonSDK
extension MastodonPickServerViewModel {
class LoadIndexedServerState: GKState {
weak var viewModel: MastodonPickServerViewModel?
init(viewModel: MastodonPickServerViewModel) {
self.viewModel = viewModel
}
override func didEnter(from previousState: GKState?) {
os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription)
}
}
}
extension MastodonPickServerViewModel.LoadIndexedServerState {
class Initial: MastodonPickServerViewModel.LoadIndexedServerState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return stateClass == Loading.self
}
}
class Loading: MastodonPickServerViewModel.LoadIndexedServerState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return stateClass == Fail.self || stateClass == Idle.self
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let viewModel = self.viewModel, let stateMachine = self.stateMachine else { return }
viewModel.isLoadingIndexedServers.value = true
viewModel.context.apiService.servers(language: nil, category: nil)
.sink { completion in
switch completion {
case .failure(let error):
// TODO: handle error
stateMachine.enter(Fail.self)
case .finished:
break
}
} receiveValue: { [weak self] response in
guard let _ = self else { return }
stateMachine.enter(Idle.self)
viewModel.indexedServers.value = response.value
}
.store(in: &viewModel.disposeBag)
}
}
class Fail: MastodonPickServerViewModel.LoadIndexedServerState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return stateClass == Loading.self
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let stateMachine = self.stateMachine else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
guard let _ = self else { return }
stateMachine.enter(Loading.self)
}
}
}
class Idle: MastodonPickServerViewModel.LoadIndexedServerState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return false
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let viewModel = self.viewModel, let stateMachine = self.stateMachine else { return }
viewModel.isLoadingIndexedServers.value = false
}
}
}

View File

@ -5,80 +5,65 @@
// Created by BradGao on 2021/2/23.
//
import os.log
import UIKit
import OSLog
import Combine
import GameplayKit
import MastodonSDK
import CoreDataStack
class MastodonPickServerViewModel: NSObject {
enum PickServerMode {
case signUp
case signIn
}
enum Category {
// `all` means search for all categories
case all
// `some` means search for specific category
case some(Mastodon.Entity.Category)
var title: String {
switch self {
case .all:
return L10n.Scene.ServerPicker.Button.Category.all
case .some(let masCategory):
// TODO: Use emoji as placeholders
switch masCategory.category {
case .academia:
return "📚"
case .activism:
return ""
case .food:
return "🍕"
case .furry:
return "🦁"
case .games:
return "🕹"
case .general:
return "GE"
case .journalism:
return "📰"
case .lgbt:
return "🏳️‍🌈"
case .regional:
return "📍"
case .art:
return "🎨"
case .music:
return "🎼"
case .tech:
return "📱"
case ._other:
return ""
}
}
}
enum EmptyStateViewState {
case none
case loading
case badNetwork
}
var disposeBag = Set<AnyCancellable>()
// input
let mode: PickServerMode
let context: AppContext
var categoryPickerItems: [CategoryPickerItem] = {
var items: [CategoryPickerItem] = []
items.append(.all)
items.append(contentsOf: APIService.stubCategories().map { CategoryPickerItem.category(category: $0) })
return items
}()
let selectCategoryItem = CurrentValueSubject<CategoryPickerItem, Never>(.all)
let searchText = CurrentValueSubject<String, Never>("")
let indexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([])
let unindexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([])
let viewWillAppear = PassthroughSubject<Void, Never>()
var categories = [Category]()
let selectCategoryIndex = CurrentValueSubject<Int, Never>(0)
let searchText = CurrentValueSubject<String?, Never>(nil)
let allServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([])
let searchedServers = CurrentValueSubject<[Mastodon.Entity.Server], Error>([])
// output
var diffableDataSource: UITableViewDiffableDataSource<PickServerSection, PickServerItem>?
private(set) lazy var loadIndexedServerStateMachine: GKStateMachine = {
// exclude timeline middle fetcher state
let stateMachine = GKStateMachine(states: [
LoadIndexedServerState.Initial(viewModel: self),
LoadIndexedServerState.Loading(viewModel: self),
LoadIndexedServerState.Fail(viewModel: self),
LoadIndexedServerState.Idle(viewModel: self),
])
stateMachine.enter(LoadIndexedServerState.Initial.self)
return stateMachine
}()
let filteredIndexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([])
let servers = CurrentValueSubject<[Mastodon.Entity.Server], Error>([])
let selectedServer = CurrentValueSubject<Mastodon.Entity.Server?, Never>(nil)
let error = PassthroughSubject<Error, Never>()
let authenticated = PassthroughSubject<(domain: String, account: Mastodon.Entity.Account), Never>()
private var disposeBag = Set<AnyCancellable>()
weak var tableView: UITableView?
let isAuthenticating = CurrentValueSubject<Bool, Never>(false)
let isLoadingIndexedServers = CurrentValueSubject<Bool, Never>(false)
let emptyStateViewState = CurrentValueSubject<EmptyStateViewState, Never>(.none)
var mastodonPinBasedAuthenticationViewController: UIViewController?
@ -91,84 +76,135 @@ class MastodonPickServerViewModel: NSObject {
}
private func configure() {
let masCategories = context.apiService.stubCategories()
categories.append(.all)
categories.append(contentsOf: masCategories.map { Category.some($0) })
Publishers.CombineLatest3(
selectCategoryIndex,
searchText.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main).removeDuplicates(),
allServers
Publishers.CombineLatest(
filteredIndexedServers.eraseToAnyPublisher(),
unindexedServers.eraseToAnyPublisher()
)
.flatMap { [weak self] (selectCategoryIndex, searchText, allServers) -> AnyPublisher<Result<[Mastodon.Entity.Server], Error>, Never> in
guard let self = self else { return Just(Result.success([])).eraseToAnyPublisher() }
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] indexedServers, unindexedServers in
guard let self = self else { return }
guard let diffableDataSource = self.diffableDataSource else { return }
// 1. Search from the servers recorded in joinmastodon.org
let searchedServersFromAPI = self.searchServersFromAPI(category: self.categories[selectCategoryIndex], searchText: searchText, allServers: allServers)
if !searchedServersFromAPI.isEmpty {
// If found servers, just return
return Just(Result.success(searchedServersFromAPI)).eraseToAnyPublisher()
}
// 2. No server found in the recorded list, check if searchText is a valid mastodon server domain
if let toSearchText = searchText, !toSearchText.isEmpty, let _ = URL(string: "https://\(toSearchText)") {
return self.context.apiService.instance(domain: toSearchText)
.map { return Result.success([Mastodon.Entity.Server(instance: $0.value)]) }
.catch({ error -> Just<Result<[Mastodon.Entity.Server], Error>> in
return Just(Result.failure(error))
})
.eraseToAnyPublisher()
}
return Just(Result.success(searchedServersFromAPI)).eraseToAnyPublisher()
}
.sink { _ in
} receiveValue: { [weak self] result in
switch result {
case .success(let servers):
self?.searchedServers.send(servers)
case .failure(let error):
// TODO: What should be presented when user inputs invalid search text?
self?.searchedServers.send([])
let oldSnapshot = diffableDataSource.snapshot()
var oldSnapshotServerItemAttributeDict: [String : PickServerItem.ServerItemAttribute] = [:]
for item in oldSnapshot.itemIdentifiers {
guard case let .server(server, attribute) = item else { continue }
oldSnapshotServerItemAttributeDict[server.domain] = attribute
}
}
var snapshot = NSDiffableDataSourceSnapshot<PickServerSection, PickServerItem>()
snapshot.appendSections([.header, .category, .search, .servers])
snapshot.appendItems([.header], toSection: .header)
snapshot.appendItems([.categoryPicker(items: self.categoryPickerItems)], toSection: .category)
snapshot.appendItems([.search], toSection: .search)
// TODO: handle filter
var serverItems: [PickServerItem] = []
for server in indexedServers {
let attribute = oldSnapshotServerItemAttributeDict[server.domain] ?? PickServerItem.ServerItemAttribute(isLast: false, isExpand: false)
attribute.isLast = false
let item = PickServerItem.server(server: server, attribute: attribute)
guard !serverItems.contains(item) else { continue }
serverItems.append(item)
}
for server in unindexedServers {
let attribute = oldSnapshotServerItemAttributeDict[server.domain] ?? PickServerItem.ServerItemAttribute(isLast: false, isExpand: false)
attribute.isLast = false
let item = PickServerItem.server(server: server, attribute: attribute)
guard !serverItems.contains(item) else { continue }
serverItems.append(item)
}
if case let .server(_, attribute) = serverItems.last {
attribute.isLast = true
}
snapshot.appendItems(serverItems, toSection: .servers)
diffableDataSource.defaultRowAnimation = .fade
diffableDataSource.apply(snapshot, animatingDifferences: true, completion: nil)
})
.store(in: &disposeBag)
}
func fetchAllServers() {
context.apiService.servers(language: nil, category: nil)
.sink { completion in
// TODO: Add a reload button when fails to fetch servers initially
} receiveValue: { [weak self] result in
self?.allServers.send(result.value)
isLoadingIndexedServers
.map { isLoadingIndexedServers -> EmptyStateViewState in
if isLoadingIndexedServers {
return .loading
} else {
return .none
}
}
.assign(to: \.value, on: emptyStateViewState)
.store(in: &disposeBag)
Publishers.CombineLatest3(
indexedServers.eraseToAnyPublisher(),
selectCategoryItem.eraseToAnyPublisher(),
searchText.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main).removeDuplicates()
)
.map { indexedServers, selectCategoryItem, searchText -> [Mastodon.Entity.Server] in
// Filter the indexed servers from joinmastodon.org
switch selectCategoryItem {
case .all:
return MastodonPickServerViewModel.filterServers(servers: indexedServers, category: nil, searchText: searchText)
case .category(let category):
return MastodonPickServerViewModel.filterServers(servers: indexedServers, category: category.category.rawValue, searchText: searchText)
}
}
.assign(to: \.value, on: filteredIndexedServers)
.store(in: &disposeBag)
searchText
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.removeDuplicates()
.compactMap { [weak self] searchText -> AnyPublisher<Result<Mastodon.Response.Content<[Mastodon.Entity.Server]>, Error>, Never>? in
// Check if searchText is a valid mastodon server domain
guard let self = self else { return nil }
guard let domain = AuthenticationViewModel.parseDomain(from: searchText) else {
return Just(Result.failure(APIService.APIError.implicit(.badRequest))).eraseToAnyPublisher()
}
return self.context.apiService.instance(domain: domain)
.map { response -> Result<Mastodon.Response.Content<[Mastodon.Entity.Server]>, Error>in
let newResponse = response.map { [Mastodon.Entity.Server(instance: $0)] }
return Result.success(newResponse)
}
.catch { error in
return Just(Result.failure(error))
}
.eraseToAnyPublisher()
}
.switchToLatest()
.sink(receiveValue: { [weak self] result in
guard let self = self else { return }
switch result {
case .success(let response):
self.unindexedServers.send(response.value)
case .failure(let error):
// TODO: What should be presented when user inputs invalid search text?
self.unindexedServers.send([])
}
})
.store(in: &disposeBag)
}
private func searchServersFromAPI(category: Category, searchText: String?, allServers: [Mastodon.Entity.Server]) -> [Mastodon.Entity.Server] {
return allServers
}
extension MastodonPickServerViewModel {
private static func filterServers(servers: [Mastodon.Entity.Server], category: String?, searchText: String) -> [Mastodon.Entity.Server] {
return servers
// 1. Filter the category
.filter {
switch category {
case .all:
return true
case .some(let masCategory):
return $0.category.caseInsensitiveCompare(masCategory.category.rawValue) == .orderedSame
}
guard let category = category else { return true }
return $0.category.caseInsensitiveCompare(category) == .orderedSame
}
// 2. Filter the searchText
.filter {
if let searchText = searchText, !searchText.isEmpty {
return $0.domain.lowercased().contains(searchText.lowercased())
} else {
let searchText = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !searchText.isEmpty else {
return true
}
return $0.domain.lowercased().contains(searchText.lowercased())
}
}
}
// MARK: - SignIn methods & structs
extension MastodonPickServerViewModel {

View File

@ -5,24 +5,20 @@
// Created by BradGao on 2021/2/23.
//
import os.log
import UIKit
import MastodonSDK
protocol PickServerCategoriesDataSource: class {
func numberOfCategories() -> Int
func category(at index: Int) -> MastodonPickServerViewModel.Category
func selectedIndex() -> Int
}
protocol PickServerCategoriesDelegate: class {
func pickServerCategoriesCell(didSelect index: Int)
protocol PickServerCategoriesCellDelegate: class {
func pickServerCategoriesCell(_ cell: PickServerCategoriesCell, collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
}
final class PickServerCategoriesCell: UITableViewCell {
weak var dataSource: PickServerCategoriesDataSource!
weak var delegate: PickServerCategoriesDelegate!
weak var delegate: PickServerCategoriesCellDelegate?
var diffableDataSource: UICollectionViewDiffableDataSource<CategoryPickerSection, CategoryPickerItem>?
let metricView = UIView()
let collectionView: UICollectionView = {
@ -38,6 +34,12 @@ final class PickServerCategoriesCell: UITableViewCell {
return view
}()
override func prepareForReuse() {
super.prepareForReuse()
delegate = nil
}
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
_init()
@ -52,8 +54,8 @@ final class PickServerCategoriesCell: UITableViewCell {
extension PickServerCategoriesCell {
private func _init() {
self.selectionStyle = .none
backgroundColor = .clear
selectionStyle = .none
backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
metricView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(metricView)
@ -75,7 +77,6 @@ extension PickServerCategoriesCell {
])
collectionView.delegate = self
collectionView.dataSource = self
}
override func layoutSubviews() {
@ -86,45 +87,26 @@ extension PickServerCategoriesCell {
}
// MARK: - UICollectionViewDelegateFlowLayout
extension PickServerCategoriesCell: UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
collectionView.selectItem(at: indexPath, animated: true, scrollPosition: .centeredHorizontally)
delegate.pickServerCategoriesCell(didSelect: indexPath.row)
}
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: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return dataSource.numberOfCategories()
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let category = dataSource.category(at: indexPath.row)
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: PickServerCategoryCollectionViewCell.self), for: indexPath) as! PickServerCategoryCollectionViewCell
cell.category = category
// Select the default category by default
if indexPath.row == dataSource.selectedIndex() {
// Use `[]` as the scrollPosition to avoid contentOffset change
collectionView.selectItem(at: indexPath, animated: false, scrollPosition: [])
cell.isSelected = true
}
return cell
}
}

View File

@ -5,24 +5,26 @@
// Created by BradGao on 2021/2/24.
//
import os.log
import UIKit
import Combine
import MastodonSDK
import Kingfisher
import AlamofireImage
import Kanna
protocol PickServerCellDelegate: class {
func pickServerCell(modeChange server: Mastodon.Entity.Server, newMode: PickServerCell.Mode, updates: (() -> Void))
func pickServerCell(_ cell: PickServerCell, expandButtonPressed button: UIButton)
}
class PickServerCell: UITableViewCell {
weak var delegate: PickServerCellDelegate?
enum Mode {
case collapse
case expand
}
var disposeBag = Set<AnyCancellable>()
private var containerView: UIView = {
let expandMode = CurrentValueSubject<ExpandMode, Never>(.collapse)
let containerView: UIView = {
let view = UIView()
view.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 10, right: 16)
view.backgroundColor = Asset.Colors.lightWhite.color
@ -30,7 +32,7 @@ class PickServerCell: UITableViewCell {
return view
}()
private var domainLabel: UILabel = {
let domainLabel: UILabel = {
let label = UILabel()
label.font = .preferredFont(forTextStyle: .headline)
label.textColor = Asset.Colors.lightDarkGray.color
@ -39,7 +41,7 @@ class PickServerCell: UITableViewCell {
return label
}()
private var checkbox: UIImageView = {
let checkbox: UIImageView = {
let imageView = UIImageView()
imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .body)
imageView.tintColor = Asset.Colors.lightSecondaryText.color
@ -48,7 +50,7 @@ class PickServerCell: UITableViewCell {
return imageView
}()
private var descriptionLabel: UILabel = {
let descriptionLabel: UILabel = {
let label = UILabel()
label.font = .preferredFont(forTextStyle: .subheadline)
label.numberOfLines = 0
@ -58,7 +60,9 @@ class PickServerCell: UITableViewCell {
return label
}()
private var thumbImageView: UIImageView = {
let thumbnailActivityIdicator = UIActivityIndicatorView(style: .medium)
let thumbnailImageView: UIImageView = {
let imageView = UIImageView()
imageView.clipsToBounds = true
imageView.contentMode = .scaleAspectFill
@ -66,7 +70,7 @@ class PickServerCell: UITableViewCell {
return imageView
}()
private var infoStackView: UIStackView = {
let infoStackView: UIStackView = {
let stackView = UIStackView()
stackView.axis = .horizontal
stackView.alignment = .fill
@ -75,14 +79,14 @@ class PickServerCell: UITableViewCell {
return stackView
}()
private var expandBox: UIView = {
let expandBox: UIView = {
let view = UIView()
view.backgroundColor = .clear
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
private var expandButton: UIButton = {
let expandButton: UIButton = {
let button = UIButton(type: .custom)
button.setTitle(L10n.Scene.ServerPicker.Button.seemore, for: .normal)
button.setTitle(L10n.Scene.ServerPicker.Button.seeless, for: .selected)
@ -92,14 +96,14 @@ class PickServerCell: UITableViewCell {
return button
}()
private var seperator: UIView = {
let seperator: UIView = {
let view = UIView()
view.backgroundColor = Asset.Colors.lightBackground.color
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
private var langValueLabel: UILabel = {
let langValueLabel: UILabel = {
let label = UILabel()
label.textColor = Asset.Colors.lightDarkGray.color
label.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: UIFont.systemFont(ofSize: 22, weight: .semibold))
@ -109,7 +113,7 @@ class PickServerCell: UITableViewCell {
return label
}()
private var usersValueLabel: UILabel = {
let usersValueLabel: UILabel = {
let label = UILabel()
label.textColor = Asset.Colors.lightDarkGray.color
label.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: UIFont.systemFont(ofSize: 22, weight: .semibold))
@ -119,7 +123,7 @@ class PickServerCell: UITableViewCell {
return label
}()
private var categoryValueLabel: UILabel = {
let categoryValueLabel: UILabel = {
let label = UILabel()
label.textColor = Asset.Colors.lightDarkGray.color
label.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: UIFont.systemFont(ofSize: 22, weight: .semibold))
@ -129,7 +133,7 @@ class PickServerCell: UITableViewCell {
return label
}()
private var langTitleLabel: UILabel = {
let langTitleLabel: UILabel = {
let label = UILabel()
label.textColor = Asset.Colors.lightDarkGray.color
label.font = .preferredFont(forTextStyle: .caption2)
@ -140,7 +144,7 @@ class PickServerCell: UITableViewCell {
return label
}()
private var usersTitleLabel: UILabel = {
let usersTitleLabel: UILabel = {
let label = UILabel()
label.textColor = Asset.Colors.lightDarkGray.color
label.font = .preferredFont(forTextStyle: .caption2)
@ -151,7 +155,7 @@ class PickServerCell: UITableViewCell {
return label
}()
private var categoryTitleLabel: UILabel = {
let categoryTitleLabel: UILabel = {
let label = UILabel()
label.textColor = Asset.Colors.lightDarkGray.color
label.font = .preferredFont(forTextStyle: .caption2)
@ -165,16 +169,13 @@ class PickServerCell: UITableViewCell {
private var collapseConstraints: [NSLayoutConstraint] = []
private var expandConstraints: [NSLayoutConstraint] = []
var mode: PickServerCell.Mode = .collapse {
didSet {
updateMode()
}
}
var server: Mastodon.Entity.Server? {
didSet {
updateServerInfo()
}
override func prepareForReuse() {
super.prepareForReuse()
thumbnailImageView.isHidden = false
thumbnailImageView.af.cancelImageRequest()
thumbnailActivityIdicator.stopAnimating()
disposeBag.removeAll()
}
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
@ -186,6 +187,7 @@ class PickServerCell: UITableViewCell {
super.init(coder: coder)
_init()
}
}
// MARK: - Methods to configure appearance
@ -204,7 +206,7 @@ extension PickServerCell {
// Always add the expandbox which contains elements only visible in expand mode
containerView.addSubview(expandBox)
expandBox.addSubview(thumbImageView)
expandBox.addSubview(thumbnailImageView)
expandBox.addSubview(infoStackView)
expandBox.isHidden = true
@ -215,7 +217,7 @@ extension PickServerCell {
infoStackView.addArrangedSubview(verticalInfoStackViewUsers)
infoStackView.addArrangedSubview(verticalInfoStackViewCategory)
let expandButtonTopConstraintInCollapse = expandButton.topAnchor.constraint(equalTo: descriptionLabel.lastBaselineAnchor, constant: 12).priority(.required)
let expandButtonTopConstraintInCollapse = expandButton.topAnchor.constraint(equalTo: descriptionLabel.lastBaselineAnchor, constant: 12).priority(.required - 1)
collapseConstraints.append(expandButtonTopConstraintInCollapse)
let expandButtonTopConstraintInExpand = expandButton.topAnchor.constraint(equalTo: expandBox.bottomAnchor, constant: 8).priority(.defaultHigh)
@ -253,20 +255,29 @@ extension PickServerCell {
expandBox.topAnchor.constraint(equalTo: descriptionLabel.bottomAnchor, constant: 8),
expandBox.bottomAnchor.constraint(equalTo: infoStackView.bottomAnchor).priority(.defaultHigh),
thumbImageView.topAnchor.constraint(equalTo: expandBox.topAnchor),
thumbImageView.leadingAnchor.constraint(equalTo: expandBox.leadingAnchor),
expandBox.trailingAnchor.constraint(equalTo: thumbImageView.trailingAnchor),
thumbImageView.heightAnchor.constraint(equalTo: thumbImageView.widthAnchor, multiplier: 151.0 / 303.0).priority(.defaultHigh),
thumbnailImageView.topAnchor.constraint(equalTo: expandBox.topAnchor),
thumbnailImageView.leadingAnchor.constraint(equalTo: expandBox.leadingAnchor),
expandBox.trailingAnchor.constraint(equalTo: thumbnailImageView.trailingAnchor),
thumbnailImageView.heightAnchor.constraint(equalTo: thumbnailImageView.widthAnchor, multiplier: 151.0 / 303.0).priority(.defaultHigh),
infoStackView.leadingAnchor.constraint(equalTo: expandBox.leadingAnchor),
expandBox.trailingAnchor.constraint(equalTo: infoStackView.trailingAnchor),
infoStackView.topAnchor.constraint(equalTo: thumbImageView.bottomAnchor, constant: 16),
infoStackView.topAnchor.constraint(equalTo: thumbnailImageView.bottomAnchor, constant: 16),
expandButton.leadingAnchor.constraint(equalTo: containerView.layoutMarginsGuide.leadingAnchor),
containerView.layoutMarginsGuide.trailingAnchor.constraint(equalTo: expandButton.trailingAnchor),
containerView.layoutMarginsGuide.bottomAnchor.constraint(equalTo: expandButton.bottomAnchor),
])
thumbnailActivityIdicator.translatesAutoresizingMaskIntoConstraints = false
thumbnailImageView.addSubview(thumbnailActivityIdicator)
NSLayoutConstraint.activate([
thumbnailActivityIdicator.centerXAnchor.constraint(equalTo: thumbnailImageView.centerXAnchor),
thumbnailActivityIdicator.centerYAnchor.constraint(equalTo: thumbnailImageView.centerYAnchor),
])
thumbnailActivityIdicator.hidesWhenStopped = true
thumbnailActivityIdicator.stopAnimating()
NSLayoutConstraint.activate(collapseConstraints)
domainLabel.setContentHuggingPriority(.required - 1, for: .vertical)
@ -274,7 +285,7 @@ extension PickServerCell {
descriptionLabel.setContentHuggingPriority(.required - 2, for: .vertical)
descriptionLabel.setContentCompressionResistancePriority(.required - 2, for: .vertical)
expandButton.addTarget(self, action: #selector(expandButtonDidClicked(_:)), for: .touchUpInside)
expandButton.addTarget(self, action: #selector(expandButtonDidPressed(_:)), for: .touchUpInside)
}
private func makeVerticalInfoStackView(arrangedView: UIView...) -> UIStackView {
@ -287,8 +298,31 @@ extension PickServerCell {
arrangedView.forEach { stackView.addArrangedSubview($0) }
return stackView
}
override func setSelected(_ selected: Bool, animated: Bool) {
super.setSelected(selected, animated: animated)
if selected {
checkbox.image = UIImage(systemName: "checkmark.circle.fill")
} else {
checkbox.image = UIImage(systemName: "circle")
}
}
private func updateMode() {
@objc
private func expandButtonDidPressed(_ sender: UIButton) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
delegate?.pickServerCell(self, expandButtonPressed: sender)
}
}
extension PickServerCell {
enum ExpandMode {
case collapse
case expand
}
func updateExpandMode(mode: ExpandMode) {
switch mode {
case .collapse:
expandBox.isHidden = true
@ -301,51 +335,8 @@ extension PickServerCell {
NSLayoutConstraint.activate(expandConstraints)
NSLayoutConstraint.deactivate(collapseConstraints)
}
expandMode.value = mode
}
override func setSelected(_ selected: Bool, animated: Bool) {
super.setSelected(selected, animated: animated)
if selected {
checkbox.image = UIImage(systemName: "checkmark.circle.fill")
} else {
checkbox.image = UIImage(systemName: "circle")
}
}
@objc
private func expandButtonDidClicked(_ sender: UIButton) {
let newMode: Mode = mode == .collapse ? .expand : .collapse
delegate?.pickServerCell(modeChange: server!, newMode: newMode, updates: { [weak self] in
self?.mode = newMode
})
}
}
// MARK: - Methods to update data
extension PickServerCell {
private func updateServerInfo() {
guard let serverInfo = server else { return }
domainLabel.text = serverInfo.domain
descriptionLabel.text = serverInfo.description
let processor = RoundCornerImageProcessor(cornerRadius: 3)
thumbImageView.kf.indicatorType = .activity
thumbImageView.kf.setImage(with: URL(string: serverInfo.proxiedThumbnail ?? "")!, placeholder: UIImage.placeholder(color: Asset.Colors.lightBackground.color), options: [
.processor(processor),
.scaleFactor(UIScreen.main.scale),
.transition(.fade(1))
])
langValueLabel.text = serverInfo.language.uppercased()
usersValueLabel.text = parseUsersCount(serverInfo.totalUsers)
categoryValueLabel.text = serverInfo.category.uppercased()
}
private func parseUsersCount(_ usersCount: Int) -> String {
switch usersCount {
case 0..<1000:
return "\(usersCount)"
default:
let usersCountInThousand = Float(usersCount) / 1000.0
return String(format: "%.1fK", usersCountInThousand)
}
}
}

View File

@ -8,7 +8,7 @@
import UIKit
protocol PickServerSearchCellDelegate: class {
func pickServerSearchCell(didChange searchText: String?)
func pickServerSearchCell(_ cell: PickServerSearchCell, searchTextDidChange searchText: String?)
}
class PickServerSearchCell: UITableViewCell {
@ -24,7 +24,7 @@ class PickServerSearchCell: UITableViewCell {
.layerMaxXMinYCorner
]
view.layer.cornerCurve = .continuous
view.layer.cornerRadius = 10
view.layer.cornerRadius = MastodonPickServerAppearance.tableViewCornerRadius
return view
}()
@ -38,7 +38,7 @@ class PickServerSearchCell: UITableViewCell {
return view
}()
private var searchTextField: UITextField = {
let searchTextField: UITextField = {
let textField = UITextField()
textField.translatesAutoresizingMaskIntoConstraints = false
textField.font = .preferredFont(forTextStyle: .headline)
@ -55,6 +55,12 @@ class PickServerSearchCell: UITableViewCell {
return textField
}()
override func prepareForReuse() {
super.prepareForReuse()
delegate = nil
}
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
_init()
@ -68,8 +74,8 @@ class PickServerSearchCell: UITableViewCell {
extension PickServerSearchCell {
private func _init() {
self.selectionStyle = .none
backgroundColor = .clear
selectionStyle = .none
backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
searchTextField.addTarget(self, action: #selector(textFieldDidChange(_:)), for: .editingChanged)
@ -97,7 +103,7 @@ extension PickServerSearchCell {
}
extension PickServerSearchCell {
@objc func textFieldDidChange(_ textField: UITextField) {
delegate?.pickServerSearchCell(didChange: textField.text)
@objc private func textFieldDidChange(_ textField: UITextField) {
delegate?.pickServerSearchCell(self, searchTextDidChange: textField.text)
}
}

View File

@ -34,8 +34,8 @@ final class PickServerTitleCell: UITableViewCell {
extension PickServerTitleCell {
private func _init() {
self.selectionStyle = .none
backgroundColor = .clear
selectionStyle = .none
backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
contentView.addSubview(titleLabel)
NSLayoutConstraint.activate([

View File

@ -9,16 +9,6 @@ import UIKit
import MastodonSDK
class PickServerCategoryView: UIView {
var category: MastodonPickServerViewModel.Category? {
didSet {
updateCategory()
}
}
var selected: Bool = false {
didSet {
updateSelectStatus()
}
}
var bgShadowView: UIView = {
let view = UIView()
@ -53,47 +43,34 @@ class PickServerCategoryView: UIView {
}
extension PickServerCategoryView {
private func configure() {
addSubview(bgView)
addSubview(titleLabel)
bgView.backgroundColor = Asset.Colors.lightWhite.color
NSLayoutConstraint.activate([
bgView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
bgView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
bgView.topAnchor.constraint(equalTo: self.topAnchor),
bgView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
titleLabel.centerXAnchor.constraint(equalTo: self.centerXAnchor),
titleLabel.centerYAnchor.constraint(equalTo: self.centerYAnchor),
])
}
private func updateCategory() {
guard let category = category else { return }
titleLabel.text = category.title
switch category {
case .all:
titleLabel.font = UIFont.systemFont(ofSize: 17)
case .some:
titleLabel.font = UIFont.systemFont(ofSize: 28)
}
}
private func updateSelectStatus() {
if selected {
bgView.backgroundColor = Asset.Colors.lightBrandBlue.color
bgView.applyShadow(color: Asset.Colors.lightBrandBlue.color, alpha: 1, x: 0, y: 0, blur: 4.0)
if case .all = category {
titleLabel.textColor = Asset.Colors.lightWhite.color
}
} else {
bgView.backgroundColor = Asset.Colors.lightWhite.color
bgView.applyShadow(color: Asset.Colors.lightBrandBlue.color, alpha: 0, x: 0, y: 0, blur: 0.0)
if case .all = category {
titleLabel.textColor = Asset.Colors.lightBrandBlue.color
}
}
#if DEBUG && canImport(SwiftUI)
import SwiftUI
struct PickServerCategoryView_Previews: PreviewProvider {
static var previews: some View {
UIViewPreview {
PickServerCategoryView()
}
}
}
#endif

View File

@ -0,0 +1,141 @@
//
// PickServerEmptyStateView.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021/3/6.
//
import UIKit
final class PickServerEmptyStateView: UIView {
var topPaddingViewTopLayoutConstraint: NSLayoutConstraint!
let networkIndicatorImageView: UIImageView = {
let imageView = UIImageView()
let configuration = UIImage.SymbolConfiguration(pointSize: 64, weight: .regular)
imageView.image = UIImage(systemName: "wifi.exclamationmark", withConfiguration: configuration)
imageView.tintColor = Asset.Colors.Label.secondary.color
return imageView
}()
let activityIndicatorView = UIActivityIndicatorView(style: .medium)
let infoLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 17)
label.textAlignment = .center
label.textColor = Asset.Colors.Label.secondary.color
label.text = "info"
label.numberOfLines = 0
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension PickServerEmptyStateView {
private func _init() {
backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color
layer.maskedCorners = [
.layerMinXMaxYCorner,
.layerMaxXMaxYCorner
]
layer.cornerCurve = .continuous
layer.cornerRadius = MastodonPickServerAppearance.tableViewCornerRadius
let topPaddingView = UIView()
topPaddingView.translatesAutoresizingMaskIntoConstraints = false
addSubview(topPaddingView)
topPaddingViewTopLayoutConstraint = topPaddingView.topAnchor.constraint(equalTo: topAnchor, constant: 0)
NSLayoutConstraint.activate([
topPaddingViewTopLayoutConstraint,
topPaddingView.leadingAnchor.constraint(equalTo: leadingAnchor),
topPaddingView.trailingAnchor.constraint(equalTo: trailingAnchor),
])
let containerStackView = UIStackView()
containerStackView.axis = .vertical
containerStackView.alignment = .center
containerStackView.distribution = .fill
containerStackView.spacing = 16
containerStackView.translatesAutoresizingMaskIntoConstraints = false
addSubview(containerStackView)
NSLayoutConstraint.activate([
containerStackView.topAnchor.constraint(equalTo: topPaddingView.bottomAnchor),
containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor),
containerStackView.trailingAnchor.constraint(equalTo: trailingAnchor),
])
containerStackView.addArrangedSubview(networkIndicatorImageView)
let infoContainerView = UIView()
activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false
infoContainerView.addSubview(activityIndicatorView)
NSLayoutConstraint.activate([
activityIndicatorView.leadingAnchor.constraint(equalTo: infoContainerView.leadingAnchor),
activityIndicatorView.centerYAnchor.constraint(equalTo: infoContainerView.centerYAnchor),
activityIndicatorView.bottomAnchor.constraint(equalTo: infoContainerView.bottomAnchor),
])
infoLabel.translatesAutoresizingMaskIntoConstraints = false
infoContainerView.addSubview(infoLabel)
NSLayoutConstraint.activate([
infoLabel.leadingAnchor.constraint(equalTo: activityIndicatorView.trailingAnchor, constant: 4),
infoLabel.centerYAnchor.constraint(equalTo: infoContainerView.centerYAnchor),
infoLabel.trailingAnchor.constraint(equalTo: infoContainerView.trailingAnchor),
])
containerStackView.addArrangedSubview(infoContainerView)
let bottomPaddingView = UIView()
bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false
addSubview(bottomPaddingView)
NSLayoutConstraint.activate([
bottomPaddingView.topAnchor.constraint(equalTo: containerStackView.bottomAnchor),
bottomPaddingView.leadingAnchor.constraint(equalTo: leadingAnchor),
bottomPaddingView.trailingAnchor.constraint(equalTo: trailingAnchor),
bottomPaddingView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
NSLayoutConstraint.activate([
bottomPaddingView.heightAnchor.constraint(equalTo: topPaddingView.heightAnchor, multiplier: 2.0),
])
activityIndicatorView.hidesWhenStopped = true
activityIndicatorView.startAnimating()
}
}
#if DEBUG && canImport(SwiftUI)
import SwiftUI
struct PickServerEmptyStateView_Previews: PreviewProvider {
static var previews: some View {
Group {
UIViewPreview(width: 375) {
let emptyStateView = PickServerEmptyStateView()
emptyStateView.infoLabel.text = L10n.Scene.ServerPicker.EmptyState.badNetwork
emptyStateView.infoLabel.textAlignment = .center
emptyStateView.activityIndicatorView.stopAnimating()
return emptyStateView
}
.previewLayout(.fixed(width: 375, height: 400))
UIViewPreview(width: 375) {
let emptyStateView = PickServerEmptyStateView()
emptyStateView.infoLabel.text = L10n.Scene.ServerPicker.EmptyState.findingServers
emptyStateView.infoLabel.textAlignment = UIApplication.shared.userInterfaceLayoutDirection == .rightToLeft ? .right : .left
emptyStateView.activityIndicatorView.startAnimating()
return emptyStateView
}
.previewLayout(.fixed(width: 375, height: 400))
}
}
}
#endif

View File

@ -39,8 +39,6 @@ final class MastodonPinBasedAuthenticationViewController: UIViewController, Need
}
extension MastodonPinBasedAuthenticationViewController {
override func viewDidLoad() {

View File

@ -0,0 +1,63 @@
//
// MastodonRegisterViewController+Avatar.swift
// Mastodon
//
// Created by sxiaojian on 2021/3/2.
//
import CropViewController
import Foundation
import PhotosUI
import UIKit
// MARK: - PHPickerViewControllerDelegate
extension MastodonRegisterViewController: PHPickerViewControllerDelegate {
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
guard let itemProvider = results.first?.itemProvider, itemProvider.canLoadObject(ofClass: UIImage.self) else {
picker.dismiss(animated: true, completion: {})
return
}
itemProvider.loadObject(ofClass: UIImage.self) { [weak self] image, error in
guard let self = self else { return }
guard let image = image as? UIImage else {
guard let error = error else { return }
let alertController = UIAlertController(for: error, title: "", preferredStyle: .alert)
let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil)
alertController.addAction(okAction)
DispatchQueue.main.async {
self.coordinator.present(
scene: .alertController(alertController: alertController),
from: nil,
transition: .alertController(animated: true, completion: nil)
)
}
return
}
DispatchQueue.main.async {
let cropController = CropViewController(croppingStyle: .default, image: image)
cropController.delegate = self
cropController.setAspectRatioPreset(.presetSquare, animated: true)
cropController.aspectRatioPickerButtonHidden = true
cropController.aspectRatioLockEnabled = true
picker.dismiss(animated: true, completion: {
self.present(cropController, animated: true, completion: nil)
})
}
}
}
}
// MARK: - CropViewControllerDelegate
extension MastodonRegisterViewController: CropViewControllerDelegate {
public func cropViewController(_ cropViewController: CropViewController, didCropToImage image: UIImage, withRect cropRect: CGRect, angle: Int) {
self.viewModel.avatarImage.value = image
self.avatarButton.setImage(image, for: .normal)
cropViewController.dismiss(animated: true, completion: nil)
}
}
extension MastodonRegisterViewController {
@objc func avatarButtonPressed(_ sender: UIButton) {
self.present(imagePicker, animated: true, completion: nil)
}
}

View File

@ -5,11 +5,12 @@
// Created by MainasuK Cirno on 2021-2-5.
//
import AlamofireImage
import Combine
import MastodonSDK
import os.log
import PhotosUI
import UIKit
import UITextField_Shake
final class MastodonRegisterViewController: UIViewController, NeedsDependency, OnboardingViewControllerAppearance {
var disposeBag = Set<AnyCancellable>()
@ -19,6 +20,15 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O
var viewModel: MastodonRegisterViewModel!
lazy var imagePicker: PHPickerViewController = {
var configuration = PHPickerConfiguration()
configuration.filter = .images
let imagePicker = PHPickerViewController(configuration: configuration)
imagePicker.delegate = self
return imagePicker
}()
let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer
let scrollView: UIScrollView = {
@ -26,7 +36,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O
scrollview.showsVerticalScrollIndicator = false
scrollview.keyboardDismissMode = .interactive
scrollview.alwaysBounceVertical = true
scrollview.clipsToBounds = false // make content could display over bleeding
scrollview.clipsToBounds = false // make content could display over bleeding
scrollview.translatesAutoresizingMaskIntoConstraints = false
return scrollview
}()
@ -39,13 +49,13 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O
return label
}()
let photoView: UIView = {
let avatarView: UIView = {
let view = UIView()
view.backgroundColor = .clear
return view
}()
let photoButton: UIButton = {
let avatarButton: UIButton = {
let button = UIButton(type: .custom)
let boldFont = UIFont.systemFont(ofSize: 42)
let configuration = UIImage.SymbolConfiguration(font: boldFont)
@ -56,26 +66,17 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O
button.backgroundColor = .white
button.layer.cornerRadius = 45
button.clipsToBounds = true
return button
}()
let plusIconBackground: UIImageView = {
let plusIconImageView: UIImageView = {
let icon = UIImageView()
let boldFont = UIFont.systemFont(ofSize: 24)
let configuration = UIImage.SymbolConfiguration(font: boldFont)
let image = UIImage(systemName: "plus.circle", withConfiguration: configuration)
icon.image = image
icon.tintColor = .white
return icon
}()
let plusIcon: UIImageView = {
let icon = UIImageView()
let boldFont = UIFont.systemFont(ofSize: 24)
let configuration = UIImage.SymbolConfiguration(font: boldFont)
let image = UIImage(systemName: "plus.circle.fill", withConfiguration: configuration)
let image = Asset.Circles.plusCircleFill.image.withRenderingMode(.alwaysTemplate)
icon.image = image
icon.tintColor = Asset.Colors.Icon.plus.color
icon.backgroundColor = .white
return icon
}()
@ -103,22 +104,10 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O
return textField
}()
let usernameIsTakenLabel: UILabel = {
let usernameErrorPromptLabel: UILabel = {
let label = UILabel()
let color = Asset.Colors.lightDangerRed.color
let font = UIFont.preferredFont(forTextStyle: .caption1)
let attributeString = NSMutableAttributedString()
let errorImage = NSTextAttachment()
let configuration = UIImage.SymbolConfiguration(font: font)
errorImage.image = UIImage(systemName: "xmark.octagon.fill", withConfiguration: configuration)?.withTintColor(color)
let errorImageAttachment = NSAttributedString(attachment: errorImage)
attributeString.append(errorImageAttachment)
let errorString = NSAttributedString(string: L10n.Common.Errors.Item.username + " " + L10n.Common.Errors.errTaken, attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: color])
attributeString.append(errorString)
label.attributedText = attributeString
return label
}()
@ -155,9 +144,10 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O
return textField
}()
let passwordCheckLabel: UILabel = {
let emailErrorPromptLabel: UILabel = {
let label = UILabel()
label.numberOfLines = 0
let color = Asset.Colors.lightDangerRed.color
let font = UIFont.preferredFont(forTextStyle: .caption1)
return label
}()
@ -179,7 +169,21 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O
return textField
}()
lazy var inviteTextField: UITextField = {
let passwordCheckLabel: UILabel = {
let label = UILabel()
label.numberOfLines = 0
return label
}()
let passwordErrorPromptLabel: UILabel = {
let label = UILabel()
let color = Asset.Colors.lightDangerRed.color
let font = UIFont.preferredFont(forTextStyle: .caption1)
return label
}()
lazy var reasonTextField: UITextField = {
let textField = UITextField()
textField.autocapitalizationType = .none
textField.autocorrectionType = .no
@ -195,6 +199,13 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O
return textField
}()
let reasonErrorPromptLabel: UILabel = {
let label = UILabel()
let color = Asset.Colors.lightDangerRed.color
let font = UIFont.preferredFont(forTextStyle: .caption1)
return label
}()
let buttonContainer = UIView()
let signUpButton: PrimaryActionButton = {
let button = PrimaryActionButton()
@ -204,9 +215,8 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O
}()
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function)
}
}
extension MastodonRegisterViewController {
@ -219,7 +229,7 @@ extension MastodonRegisterViewController {
domainLabel.text = "@" + viewModel.domain + " "
domainLabel.sizeToFit()
passwordCheckLabel.attributedText = viewModel.attributeStringForPassword()
passwordCheckLabel.attributedText = MastodonRegisterViewModel.attributeStringForPassword(validateState: .empty)
usernameTextField.rightView = domainLabel
usernameTextField.rightViewMode = .always
usernameTextField.delegate = self
@ -239,16 +249,40 @@ extension MastodonRegisterViewController {
stackView.layoutMargins = UIEdgeInsets(top: 20, left: 0, bottom: 26, right: 0)
stackView.isLayoutMarginsRelativeArrangement = true
stackView.addArrangedSubview(largeTitleLabel)
stackView.addArrangedSubview(photoView)
stackView.addArrangedSubview(avatarView)
stackView.addArrangedSubview(usernameTextField)
stackView.addArrangedSubview(usernameIsTakenLabel)
stackView.addArrangedSubview(displayNameTextField)
stackView.addArrangedSubview(emailTextField)
stackView.addArrangedSubview(passwordTextField)
stackView.addArrangedSubview(passwordCheckLabel)
if viewModel.approvalRequired {
stackView.addArrangedSubview(inviteTextField)
stackView.addArrangedSubview(reasonTextField)
}
usernameErrorPromptLabel.translatesAutoresizingMaskIntoConstraints = false
stackView.addSubview(usernameErrorPromptLabel)
NSLayoutConstraint.activate([
usernameErrorPromptLabel.topAnchor.constraint(equalTo: usernameTextField.bottomAnchor, constant: 6),
usernameErrorPromptLabel.leadingAnchor.constraint(equalTo: usernameTextField.leadingAnchor),
usernameErrorPromptLabel.trailingAnchor.constraint(equalTo: usernameTextField.trailingAnchor),
])
emailErrorPromptLabel.translatesAutoresizingMaskIntoConstraints = false
stackView.addSubview(emailErrorPromptLabel)
NSLayoutConstraint.activate([
emailErrorPromptLabel.topAnchor.constraint(equalTo: emailTextField.bottomAnchor, constant: 6),
emailErrorPromptLabel.leadingAnchor.constraint(equalTo: emailTextField.leadingAnchor),
emailErrorPromptLabel.trailingAnchor.constraint(equalTo: emailTextField.trailingAnchor),
])
passwordErrorPromptLabel.translatesAutoresizingMaskIntoConstraints = false
stackView.addSubview(passwordErrorPromptLabel)
NSLayoutConstraint.activate([
passwordErrorPromptLabel.topAnchor.constraint(equalTo: passwordCheckLabel.bottomAnchor, constant: 2),
passwordErrorPromptLabel.leadingAnchor.constraint(equalTo: passwordTextField.leadingAnchor),
passwordErrorPromptLabel.trailingAnchor.constraint(equalTo: passwordTextField.trailingAnchor),
])
// scrollView
view.addSubview(scrollView)
NSLayoutConstraint.activate([
@ -271,29 +305,24 @@ extension MastodonRegisterViewController {
])
// photoview
photoView.translatesAutoresizingMaskIntoConstraints = false
photoView.addSubview(photoButton)
avatarView.translatesAutoresizingMaskIntoConstraints = false
avatarView.addSubview(avatarButton)
NSLayoutConstraint.activate([
photoView.heightAnchor.constraint(equalToConstant: 90).priority(.defaultHigh),
avatarView.heightAnchor.constraint(equalToConstant: 90).priority(.defaultHigh),
])
photoButton.translatesAutoresizingMaskIntoConstraints = false
avatarButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
photoButton.heightAnchor.constraint(equalToConstant: 90).priority(.defaultHigh),
photoButton.widthAnchor.constraint(equalToConstant: 90).priority(.defaultHigh),
photoButton.centerXAnchor.constraint(equalTo: photoView.centerXAnchor),
photoButton.centerYAnchor.constraint(equalTo: photoView.centerYAnchor),
avatarButton.heightAnchor.constraint(equalToConstant: 90).priority(.defaultHigh),
avatarButton.widthAnchor.constraint(equalToConstant: 90).priority(.defaultHigh),
avatarButton.centerXAnchor.constraint(equalTo: avatarView.centerXAnchor),
avatarButton.centerYAnchor.constraint(equalTo: avatarView.centerYAnchor),
])
plusIconBackground.translatesAutoresizingMaskIntoConstraints = false
photoView.addSubview(plusIconBackground)
plusIconImageView.translatesAutoresizingMaskIntoConstraints = false
avatarView.addSubview(plusIconImageView)
NSLayoutConstraint.activate([
plusIconBackground.trailingAnchor.constraint(equalTo: photoButton.trailingAnchor),
plusIconBackground.bottomAnchor.constraint(equalTo: photoButton.bottomAnchor),
])
plusIcon.translatesAutoresizingMaskIntoConstraints = false
photoView.addSubview(plusIcon)
NSLayoutConstraint.activate([
plusIcon.trailingAnchor.constraint(equalTo: photoButton.trailingAnchor),
plusIcon.bottomAnchor.constraint(equalTo: photoButton.bottomAnchor),
plusIconImageView.trailingAnchor.constraint(equalTo: avatarButton.trailingAnchor),
plusIconImageView.bottomAnchor.constraint(equalTo: avatarButton.bottomAnchor),
])
// textfield
@ -354,6 +383,16 @@ extension MastodonRegisterViewController {
}
})
.store(in: &disposeBag)
avatarButton.publisher(for: \.isHighlighted, options: .new)
.receive(on: DispatchQueue.main)
.sink { [weak self] isHighlighted in
guard let self = self else { return }
let alpha: CGFloat = isHighlighted ? 0.8 : 1
self.plusIconImageView.alpha = alpha
self.avatarButton.alpha = alpha
}
.store(in: &disposeBag)
viewModel.isRegistering
.receive(on: DispatchQueue.main)
@ -370,6 +409,13 @@ extension MastodonRegisterViewController {
self.setTextFieldValidAppearance(self.usernameTextField, validateState: validateState)
}
.store(in: &disposeBag)
viewModel.usernameErrorPrompt
.receive(on: DispatchQueue.main)
.sink { [weak self] prompt in
guard let self = self else { return }
self.usernameErrorPromptLabel.attributedText = prompt
}
.store(in: &disposeBag)
viewModel.displayNameValidateState
.receive(on: DispatchQueue.main)
.sink { [weak self] validateState in
@ -384,12 +430,33 @@ extension MastodonRegisterViewController {
self.setTextFieldValidAppearance(self.emailTextField, validateState: validateState)
}
.store(in: &disposeBag)
viewModel.emailErrorPrompt
.receive(on: DispatchQueue.main)
.sink { [weak self] prompt in
guard let self = self else { return }
self.emailErrorPromptLabel.attributedText = prompt
}
.store(in: &disposeBag)
viewModel.passwordValidateState
.receive(on: DispatchQueue.main)
.sink { [weak self] validateState in
guard let self = self else { return }
self.setTextFieldValidAppearance(self.passwordTextField, validateState: validateState)
self.passwordCheckLabel.attributedText = self.viewModel.attributeStringForPassword(eightCharacters: validateState == .valid)
self.passwordCheckLabel.attributedText = MastodonRegisterViewModel.attributeStringForPassword(validateState: validateState)
}
.store(in: &disposeBag)
viewModel.passwordErrorPrompt
.receive(on: DispatchQueue.main)
.sink { [weak self] prompt in
guard let self = self else { return }
self.passwordErrorPromptLabel.attributedText = prompt
}
.store(in: &disposeBag)
viewModel.reasonErrorPrompt
.receive(on: DispatchQueue.main)
.sink { [weak self] prompt in
guard let self = self else { return }
self.reasonErrorPromptLabel.attributedText = prompt
}
.store(in: &disposeBag)
@ -401,37 +468,11 @@ extension MastodonRegisterViewController {
}
.store(in: &disposeBag)
viewModel.isUsernameTaken
.receive(on: DispatchQueue.main)
.sink {[weak self] isUsernameTaken in
guard let self = self else { return }
if isUsernameTaken {
self.usernameIsTakenLabel.isHidden = false
stackView.setCustomSpacing(6, after: self.usernameTextField)
stackView.setCustomSpacing(16, after: self.usernameIsTakenLabel)
} else {
self.usernameIsTakenLabel.isHidden = true
stackView.setCustomSpacing(40, after: self.usernameTextField)
}
}
.store(in: &disposeBag)
viewModel.error
.compactMap { $0 }
.receive(on: DispatchQueue.main)
.sink { [weak self] error in
guard let self = self else { return }
guard let error = error as? Mastodon.API.Error else { return }
switch error.mastodonError {
case .generic(let mastodonEntityError):
if let usernameTakenError = mastodonEntityError.details?.username {
let isUsernameAvaliable = usernameTakenError.filter { errorDetailReason -> Bool in
errorDetailReason.error == .ERR_TAKEN
}.isEmpty
self.viewModel.isUsernameTaken.value = !isUsernameAvaliable
}
default:
break
}
let alertController = UIAlertController(for: error, title: "Sign Up Failure", preferredStyle: .alert)
let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil)
alertController.addAction(okAction)
@ -480,37 +521,48 @@ extension MastodonRegisterViewController {
.store(in: &disposeBag)
if viewModel.approvalRequired {
inviteTextField.delegate = self
reasonTextField.delegate = self
NSLayoutConstraint.activate([
inviteTextField.heightAnchor.constraint(equalToConstant: 50).priority(.defaultHigh)
reasonTextField.heightAnchor.constraint(equalToConstant: 50).priority(.defaultHigh),
])
reasonErrorPromptLabel.translatesAutoresizingMaskIntoConstraints = false
stackView.addSubview(reasonErrorPromptLabel)
NSLayoutConstraint.activate([
reasonErrorPromptLabel.topAnchor.constraint(equalTo: reasonTextField.bottomAnchor, constant: 6),
reasonErrorPromptLabel.leadingAnchor.constraint(equalTo: reasonTextField.leadingAnchor),
reasonErrorPromptLabel.trailingAnchor.constraint(equalTo: reasonTextField.trailingAnchor),
])
viewModel.inviteValidateState
viewModel.reasonValidateState
.receive(on: DispatchQueue.main)
.sink { [weak self] validateState in
guard let self = self else { return }
self.setTextFieldValidAppearance(self.inviteTextField, validateState: validateState)
self.setTextFieldValidAppearance(self.reasonTextField, validateState: validateState)
}
.store(in: &disposeBag)
NotificationCenter.default
.publisher(for: UITextField.textDidChangeNotification, object: inviteTextField)
.publisher(for: UITextField.textDidChangeNotification, object: reasonTextField)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard let self = self else { return }
self.viewModel.reason.value = self.inviteTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
self.viewModel.reason.value = self.reasonTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
.store(in: &disposeBag)
}
avatarButton.addTarget(self, action: #selector(MastodonRegisterViewController.avatarButtonPressed(_:)), for: .touchUpInside)
signUpButton.addTarget(self, action: #selector(MastodonRegisterViewController.signUpButtonPressed(_:)), for: .touchUpInside)
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
plusIconImageView.layer.cornerRadius = plusIconImageView.frame.width / 2
plusIconImageView.layer.masksToBounds = true
}
}
extension MastodonRegisterViewController: UITextFieldDelegate {
func textFieldDidBeginEditing(_ textField: UITextField) {
let text = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
@ -523,7 +575,7 @@ extension MastodonRegisterViewController: UITextFieldDelegate {
viewModel.email.value = text
case passwordTextField:
viewModel.password.value = text
case inviteTextField:
case reasonTextField:
viewModel.reason.value = text
default:
break
@ -552,7 +604,6 @@ extension MastodonRegisterViewController: UITextFieldDelegate {
}
extension MastodonRegisterViewController {
@objc private func tapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) {
view.endEditing(true)
}
@ -572,10 +623,10 @@ extension MastodonRegisterViewController {
username: username,
email: email,
password: password,
agreement: true, // TODO:
locale: "en" // TODO:
agreement: true, // user confirmed in the server rules scene
locale: Locale.current.languageCode ?? "en"
)
// register without show server rules
context.apiService.accountRegister(
domain: viewModel.domain,
@ -595,10 +646,23 @@ extension MastodonRegisterViewController {
} receiveValue: { [weak self] response in
guard let self = self else { return }
let userToken = response.value
let viewModel = MastodonConfirmEmailViewModel(context: self.context, email: email, authenticateInfo: self.viewModel.authenticateInfo, userToken: userToken)
let updateCredentialQuery: Mastodon.API.Account.UpdateCredentialQuery = {
let displayName: String? = self.viewModel.displayName.value.isEmpty ? nil : self.viewModel.displayName.value
let avatar: Mastodon.Query.MediaAttachment? = {
guard let avatarImage = self.viewModel.avatarImage.value else { return nil }
guard avatarImage.size.width <= 400 else {
return .jpeg(avatarImage.af.imageScaled(to: CGSize(width: 400, height: 400)).jpegData(compressionQuality: 0.8))
}
return .jpeg(avatarImage.jpegData(compressionQuality: 0.8))
}()
return Mastodon.API.Account.UpdateCredentialQuery(
displayName: displayName,
avatar: avatar
)
}()
let viewModel = MastodonConfirmEmailViewModel(context: self.context, email: email, authenticateInfo: self.viewModel.authenticateInfo, userToken: userToken, updateCredentialQuery: updateCredentialQuery)
self.coordinator.present(scene: .mastodonConfirmEmail(viewModel: viewModel), from: self, transition: .show)
}
.store(in: &disposeBag)
}
}

View File

@ -24,6 +24,12 @@ final class MastodonRegisterViewModel {
let email = CurrentValueSubject<String, Never>("")
let password = CurrentValueSubject<String, Never>("")
let reason = CurrentValueSubject<String, Never>("")
let avatarImage = CurrentValueSubject<UIImage?, Never>(nil)
let usernameErrorPrompt = CurrentValueSubject<NSAttributedString?, Never>(nil)
let emailErrorPrompt = CurrentValueSubject<NSAttributedString?, Never>(nil)
let passwordErrorPrompt = CurrentValueSubject<NSAttributedString?, Never>(nil)
let reasonErrorPrompt = CurrentValueSubject<NSAttributedString?, Never>(nil)
// output
let approvalRequired: Bool
@ -32,10 +38,8 @@ final class MastodonRegisterViewModel {
let displayNameValidateState = CurrentValueSubject<ValidateState, Never>(.empty)
let emailValidateState = CurrentValueSubject<ValidateState, Never>(.empty)
let passwordValidateState = CurrentValueSubject<ValidateState, Never>(.empty)
let inviteValidateState = CurrentValueSubject<ValidateState, Never>(.empty)
let isUsernameTaken = CurrentValueSubject<Bool, Never>(false)
let reasonValidateState = CurrentValueSubject<ValidateState, Never>(.empty)
let isRegistering = CurrentValueSubject<Bool, Never>(false)
let isAllValid = CurrentValueSubject<Bool, Never>(false)
let error = CurrentValueSubject<Error?, Never>(nil)
@ -101,25 +105,43 @@ final class MastodonRegisterViewModel {
guard !invite.isEmpty else { return .empty }
return .valid
}
.assign(to: \.value, on: inviteValidateState)
.assign(to: \.value, on: reasonValidateState)
.store(in: &disposeBag)
}
error
.sink { [weak self] error in
guard let self = self else { return }
let error = error as? Mastodon.API.Error
let mastodonError = error?.mastodonError
if case let .generic(genericMastodonError) = mastodonError,
let details = genericMastodonError.details {
self.usernameErrorPrompt.value = details.usernameErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) }
self.emailErrorPrompt.value = details.emailErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) }
self.passwordErrorPrompt.value = details.passwordErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) }
self.reasonErrorPrompt.value = details.reasonErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) }
} else {
self.usernameErrorPrompt.value = nil
self.emailErrorPrompt.value = nil
self.passwordErrorPrompt.value = nil
self.reasonErrorPrompt.value = nil
}
}
.store(in: &disposeBag)
let publisherOne = Publishers.CombineLatest4(
usernameValidateState.eraseToAnyPublisher(),
displayNameValidateState.eraseToAnyPublisher(),
emailValidateState.eraseToAnyPublisher(),
passwordValidateState.eraseToAnyPublisher()
).map {
$0.0 == .valid && $0.1 == .valid && $0.2 == .valid && $0.3 == .valid
}
)
.map { $0.0 == .valid && $0.1 == .valid && $0.2 == .valid && $0.3 == .valid }
Publishers.CombineLatest(
publisherOne,
approvalRequired ? inviteValidateState.map {$0 == .valid}.eraseToAnyPublisher() : Just(true).eraseToAnyPublisher()
approvalRequired ? reasonValidateState.map {$0 == .valid}.eraseToAnyPublisher() : Just(true).eraseToAnyPublisher()
)
.map {
return $0 && $1
}
.map { $0 && $1 }
.assign(to: \.value, on: isAllValid)
.store(in: &disposeBag)
}
@ -134,6 +156,7 @@ extension MastodonRegisterViewModel {
}
extension MastodonRegisterViewModel {
static func isValidEmail(_ email: String) -> Bool {
let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
@ -141,40 +164,47 @@ extension MastodonRegisterViewModel {
return emailPred.evaluate(with: email)
}
func attributeStringForUsername() -> NSAttributedString {
let resultAttributeString = NSMutableAttributedString()
let redImage = NSTextAttachment()
let font = UIFont.preferredFont(forTextStyle: .caption1)
static func checkmarkImage(font: UIFont = .preferredFont(forTextStyle: .caption1)) -> UIImage {
let configuration = UIImage.SymbolConfiguration(font: font)
redImage.image = UIImage(systemName: "xmark.octagon.fill", withConfiguration: configuration)?.withTintColor(Asset.Colors.lightDangerRed.color)
let imageAttribute = NSAttributedString(attachment: redImage)
let stringAttribute = NSAttributedString(string: "This username is taken.", attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: Asset.Colors.lightDangerRed.color])
resultAttributeString.append(imageAttribute)
resultAttributeString.append(stringAttribute)
return resultAttributeString
return UIImage(systemName: "checkmark.circle.fill", withConfiguration: configuration)!
}
static func xmarkImage(font: UIFont = .preferredFont(forTextStyle: .caption1)) -> UIImage {
let configuration = UIImage.SymbolConfiguration(font: font)
return UIImage(systemName: "xmark.octagon.fill", withConfiguration: configuration)!
}
func attributeStringForPassword(eightCharacters: Bool = false) -> NSAttributedString {
static func attributedStringImage(with image: UIImage, tintColor: UIColor) -> NSAttributedString {
let attachment = NSTextAttachment()
attachment.image = image.withTintColor(tintColor)
return NSAttributedString(attachment: attachment)
}
static func attributeStringForPassword(validateState: ValidateState) -> NSAttributedString {
let font = UIFont.preferredFont(forTextStyle: .caption1)
let color = UIColor.black
let falseColor = UIColor.clear
let attributeString = NSMutableAttributedString()
let start = NSAttributedString(string: "Your password needs at least:", attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: color])
attributeString.append(start)
attributeString.append(checkmarkImage(color: eightCharacters ? color : falseColor))
let eightCharactersDescription = NSAttributedString(string: " Eight characters\n", attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: color])
let image = MastodonRegisterViewModel.checkmarkImage(font: font)
attributeString.append(attributedStringImage(with: image, tintColor: validateState == .valid ? .black : .clear))
attributeString.append(NSAttributedString(string: " "))
let eightCharactersDescription = NSAttributedString(string: L10n.Scene.Register.Input.Password.hint, attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: UIColor.black])
attributeString.append(eightCharactersDescription)
return attributeString
}
func checkmarkImage(color: UIColor) -> NSAttributedString {
let checkmarkImage = NSTextAttachment()
static func errorPromptAttributedString(for prompt: String) -> NSAttributedString {
let font = UIFont.preferredFont(forTextStyle: .caption1)
let configuration = UIImage.SymbolConfiguration(font: font)
checkmarkImage.image = UIImage(systemName: "checkmark.circle.fill", withConfiguration: configuration)?.withTintColor(color)
return NSAttributedString(attachment: checkmarkImage)
let attributeString = NSMutableAttributedString()
let image = MastodonRegisterViewModel.xmarkImage(font: font)
attributeString.append(attributedStringImage(with: image, tintColor: Asset.Colors.lightDangerRed.color))
attributeString.append(NSAttributedString(string: " "))
let promptAttributedString = NSAttributedString(string: prompt, attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: Asset.Colors.lightDangerRed.color])
attributeString.append(promptAttributedString)
return attributeString
}
}

View File

@ -42,21 +42,7 @@ final class AuthenticationViewModel {
input
.map { input in
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !trimmed.isEmpty else { return nil }
let urlString = trimmed.hasPrefix("https://") ? trimmed : "https://" + trimmed
guard let url = URL(string: urlString),
let host = url.host else {
return nil
}
let components = host.components(separatedBy: ".")
guard !components.contains(where: { $0.isEmpty }) else { return nil }
guard components.count >= 2 else { return nil }
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: iput host: %s", ((#file as NSString).lastPathComponent), #line, #function, host)
return host
AuthenticationViewModel.parseDomain(from: input)
}
.assign(to: \.value, on: domain)
.store(in: &disposeBag)
@ -77,6 +63,26 @@ final class AuthenticationViewModel {
}
extension AuthenticationViewModel {
static func parseDomain(from input: String) -> String? {
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !trimmed.isEmpty else { return nil }
let urlString = trimmed.hasPrefix("https://") ? trimmed : "https://" + trimmed
guard let url = URL(string: urlString),
let host = url.host else {
return nil
}
let components = host.components(separatedBy: ".")
guard !components.contains(where: { $0.isEmpty }) else { return nil }
guard components.count >= 2 else { return nil }
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: input host: %s", ((#file as NSString).lastPathComponent), #line, #function, host)
return host
}
}
extension AuthenticationViewModel {
enum AuthenticationError: Error, LocalizedError {
case badCredentials

View File

@ -13,7 +13,7 @@ final class WelcomeViewController: UIViewController, NeedsDependency {
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
let welcomeIllustrationView = WelcomeIllustrationView()
private(set) lazy var welcomeIllustrationView = WelcomeIllustrationView()
var welcomeIllustrationViewBottomAnchorLayoutConstraint: NSLayoutConstraint?
private(set) lazy var logoImageView: UIImageView = {
@ -34,9 +34,14 @@ final class WelcomeViewController: UIViewController, NeedsDependency {
return label
}()
let signUpButton: PrimaryActionButton = {
private(set) lazy var signUpButton: PrimaryActionButton = {
let button = PrimaryActionButton()
button.setTitle(L10n.Common.Controls.Actions.signUp, for: .normal)
let backgroundImageColor: UIColor = traitCollection.userInterfaceIdiom == .phone ? .white : Asset.Colors.Button.highlight.color
button.setBackgroundImage(.placeholder(color: backgroundImageColor), for: .normal)
button.setBackgroundImage(.placeholder(color: backgroundImageColor.withAlphaComponent(0.8)), for: .highlighted)
let titleColor: UIColor = traitCollection.userInterfaceIdiom == .phone ? Asset.Colors.Button.highlight.color : UIColor.white
button.setTitleColor(titleColor, for: .normal)
button.translatesAutoresizingMaskIntoConstraints = false
return button
}()
@ -65,106 +70,108 @@ extension WelcomeViewController {
setupOnboardingAppearance()
if traitCollection.userInterfaceIdiom == .phone {
welcomeIllustrationView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(welcomeIllustrationView)
welcomeIllustrationViewBottomAnchorLayoutConstraint = welcomeIllustrationView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
NSLayoutConstraint.activate([
view.leftAnchor.constraint(equalTo: welcomeIllustrationView.leftAnchor, constant: 44),
welcomeIllustrationView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: 44),
welcomeIllustrationViewBottomAnchorLayoutConstraint!,
])
view.backgroundColor = .black
welcomeIllustrationView.alpha = 0.9
welcomeIllustrationView.cloudBaseImageView.addMotionEffect(
UIInterpolatingMotionEffect.motionEffect(minX: -5, maxX: 5, minY: -5, maxY: 5)
)
welcomeIllustrationView.rightHillImageView.addMotionEffect(
UIInterpolatingMotionEffect.motionEffect(minX: -12, maxX: 12, minY: -12, maxY: 12)
)
welcomeIllustrationView.leftHillImageView.addMotionEffect(
UIInterpolatingMotionEffect.motionEffect(minX: -12, maxX: 12, minY: -20, maxY: 20)
)
welcomeIllustrationView.centerHillImageView.addMotionEffect(
UIInterpolatingMotionEffect.motionEffect(minX: -14, maxX: 14, minY: -30, maxY: 30)
)
welcomeIllustrationView.lineDashTwoImageView.addMotionEffect(
UIInterpolatingMotionEffect.motionEffect(minX: -25, maxX: 25, minY: -40, maxY: 40)
)
welcomeIllustrationView.elephantTwoImageView.addMotionEffect(
UIInterpolatingMotionEffect.motionEffect(minX: -30, maxX: 30, minY: -30, maxY: 30)
)
// welcomeIllustrationView.translatesAutoresizingMaskIntoConstraints = false
// view.addSubview(welcomeIllustrationView)
// welcomeIllustrationViewBottomAnchorLayoutConstraint = welcomeIllustrationView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
// NSLayoutConstraint.activate([
// view.leftAnchor.constraint(equalTo: welcomeIllustrationView.leftAnchor, constant: 44),
// welcomeIllustrationView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: 44),
// welcomeIllustrationViewBottomAnchorLayoutConstraint!,
// ])
// view.backgroundColor = .black
// welcomeIllustrationView.alpha = 0.9
// welcomeIllustrationView.cloudBaseImageView.addMotionEffect(
// UIInterpolatingMotionEffect.motionEffect(minX: -5, maxX: 5, minY: -5, maxY: 5)
// )
// welcomeIllustrationView.rightHillImageView.addMotionEffect(
// UIInterpolatingMotionEffect.motionEffect(minX: -12, maxX: 12, minY: -12, maxY: 12)
// )
// welcomeIllustrationView.leftHillImageView.addMotionEffect(
// UIInterpolatingMotionEffect.motionEffect(minX: -12, maxX: 12, minY: -20, maxY: 20)
// )
// welcomeIllustrationView.centerHillImageView.addMotionEffect(
// UIInterpolatingMotionEffect.motionEffect(minX: -14, maxX: 14, minY: -30, maxY: 30)
// )
// welcomeIllustrationView.lineDashTwoImageView.addMotionEffect(
// UIInterpolatingMotionEffect.motionEffect(minX: -25, maxX: 25, minY: -40, maxY: 40)
// )
// welcomeIllustrationView.elephantTwoImageView.addMotionEffect(
// UIInterpolatingMotionEffect.motionEffect(minX: -30, maxX: 30, minY: -30, maxY: 30)
// )
}
view.addSubview(logoImageView)
NSLayoutConstraint.activate([
logoImageView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor),
logoImageView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor, constant: 35),
view.readableContentGuide.trailingAnchor.constraint(equalTo: logoImageView.trailingAnchor, constant: 35),
logoImageView.heightAnchor.constraint(equalTo: logoImageView.widthAnchor, multiplier: 65.4/265.1),
])
view.addSubview(sloganLabel)
NSLayoutConstraint.activate([
sloganLabel.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor, constant: 16),
view.readableContentGuide.trailingAnchor.constraint(equalTo: sloganLabel.trailingAnchor, constant: 16),
sloganLabel.topAnchor.constraint(equalTo: logoImageView.bottomAnchor, constant: 168),
])
if traitCollection.userInterfaceIdiom == .phone {
let imageSizeScale: CGFloat = view.frame.width > 375 ? 1.5 : 1.0
welcomeIllustrationView.cloudFirstImageView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(welcomeIllustrationView.cloudFirstImageView)
// view.addSubview(logoImageView)
// NSLayoutConstraint.activate([
// logoImageView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor),
// logoImageView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor, constant: 35),
// view.readableContentGuide.trailingAnchor.constraint(equalTo: logoImageView.trailingAnchor, constant: 35),
// logoImageView.heightAnchor.constraint(equalTo: logoImageView.widthAnchor, multiplier: 65.4/265.1),
// ])
if traitCollection.userInterfaceIdiom != .phone {
view.addSubview(sloganLabel)
NSLayoutConstraint.activate([
welcomeIllustrationView.cloudFirstImageView.rightAnchor.constraint(equalTo: view.centerXAnchor),
welcomeIllustrationView.cloudFirstImageView.widthAnchor.constraint(equalToConstant: 272 / traitCollection.displayScale * imageSizeScale),
welcomeIllustrationView.cloudFirstImageView.heightAnchor.constraint(equalToConstant: 113 / traitCollection.displayScale * imageSizeScale),
sloganLabel.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor, constant: 16),
view.readableContentGuide.trailingAnchor.constraint(equalTo: sloganLabel.trailingAnchor, constant: 16),
sloganLabel.topAnchor.constraint(equalTo: logoImageView.bottomAnchor, constant: 168),
])
welcomeIllustrationView.cloudSecondImageView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(welcomeIllustrationView.cloudSecondImageView)
NSLayoutConstraint.activate([
welcomeIllustrationView.cloudSecondImageView.topAnchor.constraint(equalTo: logoImageView.bottomAnchor),
welcomeIllustrationView.cloudSecondImageView.rightAnchor.constraint(equalTo: logoImageView.rightAnchor, constant: 20),
welcomeIllustrationView.cloudSecondImageView.widthAnchor.constraint(equalToConstant: 152 / traitCollection.displayScale),
welcomeIllustrationView.cloudSecondImageView.heightAnchor.constraint(equalToConstant: 96 / traitCollection.displayScale),
welcomeIllustrationView.cloudFirstImageView.topAnchor.constraint(equalTo: welcomeIllustrationView.cloudSecondImageView.bottomAnchor),
])
welcomeIllustrationView.cloudThirdImageView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(welcomeIllustrationView.cloudThirdImageView)
NSLayoutConstraint.activate([
logoImageView.topAnchor.constraint(equalTo: welcomeIllustrationView.cloudThirdImageView.bottomAnchor, constant: 10),
welcomeIllustrationView.cloudThirdImageView.rightAnchor.constraint(equalTo: view.centerXAnchor),
welcomeIllustrationView.cloudThirdImageView.widthAnchor.constraint(equalToConstant: 126 / traitCollection.displayScale),
welcomeIllustrationView.cloudThirdImageView.heightAnchor.constraint(equalToConstant: 68 / traitCollection.displayScale),
])
welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(welcomeIllustrationView.elephantOnAirplaneWithContrailImageView)
NSLayoutConstraint.activate([
view.leftAnchor.constraint(equalTo: welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.leftAnchor, constant: 12), // add 12pt bleeding
welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.bottomAnchor.constraint(equalTo: sloganLabel.topAnchor),
// make a little bit large
welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.widthAnchor.constraint(equalToConstant: 656 / traitCollection.displayScale * imageSizeScale),
welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.heightAnchor.constraint(equalToConstant: 195 / traitCollection.displayScale * imageSizeScale),
])
welcomeIllustrationView.cloudFirstImageView.addMotionEffect(
UIInterpolatingMotionEffect.motionEffect(minX: -30, maxX: 30, minY: -20, maxY: 10)
)
welcomeIllustrationView.cloudSecondImageView.addMotionEffect(
UIInterpolatingMotionEffect.motionEffect(minX: -10, maxX: 30, minY: -8, maxY: 10)
)
welcomeIllustrationView.cloudThirdImageView.addMotionEffect(
UIInterpolatingMotionEffect.motionEffect(minX: -20, maxX: 10, minY: -6, maxY: 10)
)
welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.addMotionEffect(
UIInterpolatingMotionEffect.motionEffect(minX: -20, maxX: 12, minY: -20, maxY: 12) // maxX should not larger then the bleeding (12pt)
)
view.bringSubviewToFront(logoImageView)
view.bringSubviewToFront(sloganLabel)
}
// if traitCollection.userInterfaceIdiom == .phone {
// let imageSizeScale: CGFloat = view.frame.width > 375 ? 1.5 : 1.0
// welcomeIllustrationView.cloudFirstImageView.translatesAutoresizingMaskIntoConstraints = false
// view.addSubview(welcomeIllustrationView.cloudFirstImageView)
// NSLayoutConstraint.activate([
// welcomeIllustrationView.cloudFirstImageView.rightAnchor.constraint(equalTo: view.centerXAnchor),
// welcomeIllustrationView.cloudFirstImageView.widthAnchor.constraint(equalToConstant: 272 / traitCollection.displayScale * imageSizeScale),
// welcomeIllustrationView.cloudFirstImageView.heightAnchor.constraint(equalToConstant: 113 / traitCollection.displayScale * imageSizeScale),
// ])
// welcomeIllustrationView.cloudSecondImageView.translatesAutoresizingMaskIntoConstraints = false
// view.addSubview(welcomeIllustrationView.cloudSecondImageView)
// NSLayoutConstraint.activate([
// welcomeIllustrationView.cloudSecondImageView.topAnchor.constraint(equalTo: logoImageView.bottomAnchor),
// welcomeIllustrationView.cloudSecondImageView.rightAnchor.constraint(equalTo: logoImageView.rightAnchor, constant: 20),
// welcomeIllustrationView.cloudSecondImageView.widthAnchor.constraint(equalToConstant: 152 / traitCollection.displayScale),
// welcomeIllustrationView.cloudSecondImageView.heightAnchor.constraint(equalToConstant: 96 / traitCollection.displayScale),
// welcomeIllustrationView.cloudFirstImageView.topAnchor.constraint(equalTo: welcomeIllustrationView.cloudSecondImageView.bottomAnchor),
// ])
// welcomeIllustrationView.cloudThirdImageView.translatesAutoresizingMaskIntoConstraints = false
// view.addSubview(welcomeIllustrationView.cloudThirdImageView)
// NSLayoutConstraint.activate([
// logoImageView.topAnchor.constraint(equalTo: welcomeIllustrationView.cloudThirdImageView.bottomAnchor, constant: 10),
// welcomeIllustrationView.cloudThirdImageView.rightAnchor.constraint(equalTo: view.centerXAnchor),
// welcomeIllustrationView.cloudThirdImageView.widthAnchor.constraint(equalToConstant: 126 / traitCollection.displayScale),
// welcomeIllustrationView.cloudThirdImageView.heightAnchor.constraint(equalToConstant: 68 / traitCollection.displayScale),
// ])
//
// welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.translatesAutoresizingMaskIntoConstraints = false
// view.addSubview(welcomeIllustrationView.elephantOnAirplaneWithContrailImageView)
// NSLayoutConstraint.activate([
// view.leftAnchor.constraint(equalTo: welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.leftAnchor, constant: 12), // add 12pt bleeding
// welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.bottomAnchor.constraint(equalTo: sloganLabel.topAnchor),
// // make a little bit large
// welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.widthAnchor.constraint(equalToConstant: 656 / traitCollection.displayScale * imageSizeScale),
// welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.heightAnchor.constraint(equalToConstant: 195 / traitCollection.displayScale * imageSizeScale),
// ])
//
// welcomeIllustrationView.cloudFirstImageView.addMotionEffect(
// UIInterpolatingMotionEffect.motionEffect(minX: -30, maxX: 30, minY: -20, maxY: 10)
// )
// welcomeIllustrationView.cloudSecondImageView.addMotionEffect(
// UIInterpolatingMotionEffect.motionEffect(minX: -10, maxX: 30, minY: -8, maxY: 10)
// )
// welcomeIllustrationView.cloudThirdImageView.addMotionEffect(
// UIInterpolatingMotionEffect.motionEffect(minX: -20, maxX: 10, minY: -6, maxY: 10)
// )
// welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.addMotionEffect(
// UIInterpolatingMotionEffect.motionEffect(minX: -20, maxX: 12, minY: -20, maxY: 12) // maxX should not larger then the bleeding (12pt)
// )
//
// view.bringSubviewToFront(logoImageView)
// view.bringSubviewToFront(sloganLabel)
// }
view.addSubview(signInButton)
view.addSubview(signUpButton)
NSLayoutConstraint.activate([
@ -172,13 +179,13 @@ extension WelcomeViewController {
view.readableContentGuide.trailingAnchor.constraint(equalTo: signInButton.trailingAnchor, constant: WelcomeViewController.actionButtonMargin),
view.layoutMarginsGuide.bottomAnchor.constraint(equalTo: signInButton.bottomAnchor, constant: WelcomeViewController.viewBottomPaddingHeight),
signInButton.heightAnchor.constraint(equalToConstant: WelcomeViewController.actionButtonHeight).priority(.defaultHigh),
signInButton.topAnchor.constraint(equalTo: signUpButton.bottomAnchor, constant: 9),
signUpButton.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor, constant: WelcomeViewController.actionButtonMargin),
view.readableContentGuide.trailingAnchor.constraint(equalTo: signUpButton.trailingAnchor, constant: WelcomeViewController.actionButtonMargin),
signUpButton.heightAnchor.constraint(equalToConstant: WelcomeViewController.actionButtonHeight).priority(.defaultHigh),
])
signUpButton.addTarget(self, action: #selector(signUpButtonDidClicked(_:)), for: .touchUpInside)
signInButton.addTarget(self, action: #selector(signInButtonDidClicked(_:)), for: .touchUpInside)
}

View File

@ -8,12 +8,13 @@
import os.log
import UIKit
import Combine
import CoreData
import CoreDataStack
import MastodonSDK
// MARK: - StatusProvider
extension PublicTimelineViewController: StatusProvider {
func toot() -> Future<Toot?, Never> {
return Future { promise in promise(.success(nil)) }
}
@ -48,25 +49,25 @@ extension PublicTimelineViewController: StatusProvider {
return Future { promise in promise(.success(nil)) }
}
var managedObjectContext: NSManagedObjectContext {
return viewModel.fetchedResultsController.managedObjectContext
}
var tableViewDiffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? {
return viewModel.diffableDataSource
}
func item(for cell: UITableViewCell, indexPath: IndexPath?) -> Future<Item?, Never> {
return Future { promise in
guard let diffableDataSource = self.viewModel.diffableDataSource else {
assertionFailure()
promise(.success(nil))
return
}
guard let indexPath = indexPath ?? self.tableView.indexPath(for: cell),
let item = diffableDataSource.itemIdentifier(for: indexPath) else {
promise(.success(nil))
return
}
promise(.success(item))
func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? {
guard let diffableDataSource = self.viewModel.diffableDataSource else {
assertionFailure()
return nil
}
guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }),
let item = diffableDataSource.itemIdentifier(for: indexPath) else {
return nil
}
return item
}
}

View File

@ -76,11 +76,16 @@ extension PublicTimelineViewController {
viewModel.setupDiffableDataSource(
for: tableView,
dependency: self,
timelinePostTableViewCellDelegate: self,
statusTableViewCellDelegate: self,
timelineMiddleLoaderTableViewCellDelegate: self
)
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
context.videoPlaybackService.viewDidDisappear(from: self)
context.audioPlaybackService.viewDidDisappear(from: self)
}
}
// MARK: - UIScrollViewDelegate
@ -114,8 +119,11 @@ extension PublicTimelineViewController: UITableViewDelegate {
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
handleTableView(tableView, willDisplay: cell, forRowAt: indexPath)
}
func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
guard let diffableDataSource = viewModel.diffableDataSource else { return }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
@ -204,5 +212,21 @@ extension PublicTimelineViewController: TimelineMiddleLoaderTableViewCellDelegat
}
}
// MARK: - AVPlayerViewControllerDelegate
extension PublicTimelineViewController: AVPlayerViewControllerDelegate {
func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator)
}
func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator)
}
}
// MARK: - StatusTableViewCellDelegate
extension PublicTimelineViewController: StatusTableViewCellDelegate { }
extension PublicTimelineViewController: StatusTableViewCellDelegate {
weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self }
func parent() -> UIViewController { return self }
}

View File

@ -14,7 +14,7 @@ extension PublicTimelineViewModel {
func setupDiffableDataSource(
for tableView: UITableView,
dependency: NeedsDependency,
timelinePostTableViewCellDelegate: StatusTableViewCellDelegate,
statusTableViewCellDelegate: StatusTableViewCellDelegate,
timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate
) {
let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common)
@ -27,7 +27,7 @@ extension PublicTimelineViewModel {
dependency: dependency,
managedObjectContext: fetchedResultsController.managedObjectContext,
timestampUpdatePublisher: timestampUpdatePublisher,
timelinePostTableViewCellDelegate: timelinePostTableViewCellDelegate,
statusTableViewCellDelegate: statusTableViewCellDelegate,
timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate
)
items.value = []
@ -50,7 +50,7 @@ extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate {
return indexes.firstIndex(of: toot.id).map { index in (index, toot) }
}
.sorted { $0.0 < $1.0 }
var oldSnapshotAttributeDict: [NSManagedObjectID: Item.StatusTimelineAttribute] = [:]
var oldSnapshotAttributeDict: [NSManagedObjectID: Item.StatusAttribute] = [:]
for item in self.items.value {
guard case let .toot(objectID, attribute) = item else { continue }
oldSnapshotAttributeDict[objectID] = attribute
@ -63,7 +63,7 @@ extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate {
guard let spoilerText = targetToot.spoilerText, !spoilerText.isEmpty else { return false }
return true
}()
let attribute = oldSnapshotAttributeDict[toot.objectID] ?? Item.StatusTimelineAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: targetToot.sensitive)
let attribute = oldSnapshotAttributeDict[toot.objectID] ?? Item.StatusAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: targetToot.sensitive)
items.append(Item.toot(objectID: toot.objectID, attribute: attribute))
if tootIDsWhichHasGap.contains(toot.id) {
items.append(Item.publicMiddleLoader(tootID: toot.id))

View File

@ -0,0 +1,111 @@
//
// AudioViewContainer.swift
// Mastodon
//
// Created by sxiaojian on 2021/3/8.
//
import CoreDataStack
import os.log
import UIKit
final class AudioContainerView: UIView {
static let cornerRadius: CGFloat = 22
let container: UIStackView = {
let stackView = UIStackView()
stackView.axis = .horizontal
stackView.distribution = .fill
stackView.alignment = .center
stackView.spacing = 11
stackView.layoutMargins = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8)
stackView.isLayoutMarginsRelativeArrangement = true
stackView.layer.cornerRadius = AudioContainerView.cornerRadius
stackView.clipsToBounds = true
stackView.backgroundColor = Asset.Colors.Button.highlight.color
stackView.translatesAutoresizingMaskIntoConstraints = false
return stackView
}()
let playButtonBackgroundView: UIView = {
let view = UIView()
view.layer.cornerRadius = 16
view.clipsToBounds = true
view.backgroundColor = Asset.Colors.Button.highlight.color
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
let playButton: UIButton = {
let button = HighlightDimmableButton(type: .custom)
let image = UIImage(systemName: "play.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 32, weight: .bold))!
button.setImage(image.withRenderingMode(.alwaysTemplate), for: .normal)
let pauseImage = UIImage(systemName: "pause.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 32, weight: .bold))!
button.setImage(pauseImage.withRenderingMode(.alwaysTemplate), for: .selected)
button.tintColor = .white
button.translatesAutoresizingMaskIntoConstraints = false
button.isEnabled = true
return button
}()
let slider: UISlider = {
let slider = UISlider()
slider.translatesAutoresizingMaskIntoConstraints = false
slider.minimumTrackTintColor = Asset.Colors.Slider.bar.color
slider.maximumTrackTintColor = Asset.Colors.Slider.bar.color
if let image = UIImage.placeholder(size: CGSize(width: 22, height: 22), color: .white).withRoundedCorners(radius: 11) {
slider.setThumbImage(image, for: .normal)
}
return slider
}()
let timeLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.font = .systemFont(ofSize: 13, weight: .regular)
label.textColor = .white
label.textAlignment = UIApplication.shared.userInterfaceLayoutDirection == .leftToRight ? .right : .left
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension AudioContainerView {
private func _init() {
addSubview(container)
NSLayoutConstraint.activate([
container.topAnchor.constraint(equalTo: topAnchor),
container.leadingAnchor.constraint(equalTo: leadingAnchor),
trailingAnchor.constraint(equalTo: container.trailingAnchor),
bottomAnchor.constraint(equalTo: container.bottomAnchor),
])
// checkmark
playButtonBackgroundView.addSubview(playButton)
container.addArrangedSubview(playButtonBackgroundView)
NSLayoutConstraint.activate([
playButton.centerXAnchor.constraint(equalTo: playButtonBackgroundView.centerXAnchor),
playButton.centerYAnchor.constraint(equalTo: playButtonBackgroundView.centerYAnchor),
playButtonBackgroundView.heightAnchor.constraint(equalToConstant: 32),
playButtonBackgroundView.widthAnchor.constraint(equalToConstant: 32),
])
container.addArrangedSubview(slider)
container.addArrangedSubview(timeLabel)
NSLayoutConstraint.activate([
timeLabel.widthAnchor.constraint(equalToConstant: 40),
])
}
}

View File

@ -15,15 +15,12 @@ protocol MosaicImageViewContainerPresentable: class {
protocol MosaicImageViewContainerDelegate: class {
func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int)
func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView)
func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
}
final class MosaicImageViewContainer: UIView {
static let cornerRadius: CGFloat = 4
static let blurVisualEffect = UIBlurEffect(style: .systemUltraThinMaterial)
weak var delegate: MosaicImageViewContainerDelegate?
let container = UIStackView()
@ -37,14 +34,10 @@ final class MosaicImageViewContainer: UIView {
}
}
}
let blurVisualEffectView = UIVisualEffectView(effect: MosaicImageViewContainer.blurVisualEffect)
let vibrancyVisualEffectView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: MosaicImageViewContainer.blurVisualEffect))
let contentWarningLabel: UILabel = {
let label = UILabel()
label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15))
label.text = L10n.Common.Controls.Status.mediaContentWarning
label.textAlignment = .center
return label
let contentWarningOverlayView: ContentWarningOverlayView = {
let contentWarningOverlayView = ContentWarningOverlayView()
return contentWarningOverlayView
}()
private var containerHeightLayoutConstraint: NSLayoutConstraint!
@ -61,6 +54,12 @@ final class MosaicImageViewContainer: UIView {
}
extension MosaicImageViewContainer: ContentWarningOverlayViewDelegate {
func contentWarningOverlayViewDidPressed(_ contentWarningOverlayView: ContentWarningOverlayView) {
self.delegate?.mosaicImageViewContainer(self, contentWarningOverlayViewDidPressed: contentWarningOverlayView)
}
}
extension MosaicImageViewContainer {
private func _init() {
@ -77,32 +76,7 @@ extension MosaicImageViewContainer {
containerHeightLayoutConstraint
])
// add blur visual effect view in the setup method
blurVisualEffectView.layer.masksToBounds = true
blurVisualEffectView.layer.cornerRadius = MosaicImageViewContainer.cornerRadius
blurVisualEffectView.layer.cornerCurve = .continuous
vibrancyVisualEffectView.translatesAutoresizingMaskIntoConstraints = false
blurVisualEffectView.contentView.addSubview(vibrancyVisualEffectView)
NSLayoutConstraint.activate([
vibrancyVisualEffectView.topAnchor.constraint(equalTo: blurVisualEffectView.topAnchor),
vibrancyVisualEffectView.leadingAnchor.constraint(equalTo: blurVisualEffectView.leadingAnchor),
vibrancyVisualEffectView.trailingAnchor.constraint(equalTo: blurVisualEffectView.trailingAnchor),
vibrancyVisualEffectView.bottomAnchor.constraint(equalTo: blurVisualEffectView.bottomAnchor),
])
contentWarningLabel.translatesAutoresizingMaskIntoConstraints = false
vibrancyVisualEffectView.contentView.addSubview(contentWarningLabel)
NSLayoutConstraint.activate([
contentWarningLabel.leadingAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.layoutMarginsGuide.leadingAnchor),
contentWarningLabel.trailingAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.layoutMarginsGuide.trailingAnchor),
contentWarningLabel.centerYAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.centerYAnchor),
])
blurVisualEffectView.isUserInteractionEnabled = true
let tapGesture = UITapGestureRecognizer.singleTapGestureRecognizer
tapGesture.addTarget(self, action: #selector(MosaicImageViewContainer.visualEffectViewTapGestureRecognizerHandler(_:)))
blurVisualEffectView.addGestureRecognizer(tapGesture)
contentWarningOverlayView.delegate = self
}
}
@ -117,9 +91,10 @@ extension MosaicImageViewContainer {
container.subviews.forEach { subview in
subview.removeFromSuperview()
}
blurVisualEffectView.removeFromSuperview()
blurVisualEffectView.effect = MosaicImageViewContainer.blurVisualEffect
vibrancyVisualEffectView.alpha = 1.0
contentWarningOverlayView.removeFromSuperview()
contentWarningOverlayView.blurVisualEffectView.effect = ContentWarningOverlayView.blurVisualEffect
contentWarningOverlayView.vibrancyVisualEffectView.alpha = 1.0
contentWarningOverlayView.isUserInteractionEnabled = true
imageViews = []
container.spacing = 1
@ -140,7 +115,7 @@ extension MosaicImageViewContainer {
let imageView = UIImageView()
imageViews.append(imageView)
imageView.layer.masksToBounds = true
imageView.layer.cornerRadius = MosaicImageViewContainer.cornerRadius
imageView.layer.cornerRadius = ContentWarningOverlayView.cornerRadius
imageView.layer.cornerCurve = .continuous
imageView.contentMode = .scaleAspectFill
@ -155,13 +130,12 @@ extension MosaicImageViewContainer {
containerHeightLayoutConstraint.constant = floor(rect.height)
containerHeightLayoutConstraint.isActive = true
blurVisualEffectView.translatesAutoresizingMaskIntoConstraints = false
addSubview(blurVisualEffectView)
addSubview(contentWarningOverlayView)
NSLayoutConstraint.activate([
blurVisualEffectView.topAnchor.constraint(equalTo: imageView.topAnchor),
blurVisualEffectView.leadingAnchor.constraint(equalTo: imageView.leadingAnchor),
blurVisualEffectView.trailingAnchor.constraint(equalTo: imageView.trailingAnchor),
blurVisualEffectView.bottomAnchor.constraint(equalTo: imageView.bottomAnchor),
contentWarningOverlayView.topAnchor.constraint(equalTo: imageView.topAnchor),
contentWarningOverlayView.leadingAnchor.constraint(equalTo: imageView.leadingAnchor),
contentWarningOverlayView.trailingAnchor.constraint(equalTo: imageView.trailingAnchor),
contentWarningOverlayView.bottomAnchor.constraint(equalTo: imageView.bottomAnchor),
])
return imageView
@ -193,7 +167,7 @@ extension MosaicImageViewContainer {
self.imageViews.append(contentsOf: imageViews)
imageViews.forEach { imageView in
imageView.layer.masksToBounds = true
imageView.layer.cornerRadius = MosaicImageViewContainer.cornerRadius
imageView.layer.cornerRadius = ContentWarningOverlayView.cornerRadius
imageView.layer.cornerCurve = .continuous
imageView.contentMode = .scaleAspectFill
}
@ -242,13 +216,12 @@ extension MosaicImageViewContainer {
}
}
blurVisualEffectView.translatesAutoresizingMaskIntoConstraints = false
addSubview(blurVisualEffectView)
addSubview(contentWarningOverlayView)
NSLayoutConstraint.activate([
blurVisualEffectView.topAnchor.constraint(equalTo: container.topAnchor),
blurVisualEffectView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
blurVisualEffectView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
blurVisualEffectView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
contentWarningOverlayView.topAnchor.constraint(equalTo: container.topAnchor),
contentWarningOverlayView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
contentWarningOverlayView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
contentWarningOverlayView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
])
return imageViews
@ -260,7 +233,7 @@ extension MosaicImageViewContainer {
@objc private func visualEffectViewTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
delegate?.mosaicImageViewContainer(self, didTapContentWarningVisualEffectView: blurVisualEffectView)
delegate?.mosaicImageViewContainer(self, contentWarningOverlayViewDidPressed: contentWarningOverlayView)
}
@objc private func photoTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) {

View File

@ -0,0 +1,138 @@
//
// PlayerContainerView+MediaTypeIndicotorView.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-15.
//
import UIKit
extension PlayerContainerView {
final class MediaTypeIndicotorView: UIView {
static let indicatorViewSize = CGSize(width: 47, height: 25)
let maskLayer = CAShapeLayer()
let label: UILabel = {
let label = UILabel()
label.textColor = .white
label.textAlignment = .right
label.adjustsFontSizeToFitWidth = true
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
override func layoutSubviews() {
super.layoutSubviews()
let path = UIBezierPath()
path.move(to: CGPoint(x: bounds.width, y: bounds.height))
path.addLine(to: CGPoint(x: bounds.width, y: 0))
path.addLine(to: CGPoint(x: bounds.width * 0.5, y: 0))
path.addCurve(
to: CGPoint(x: 0, y: bounds.height),
controlPoint1: CGPoint(x: bounds.width * 0.2, y: 0),
controlPoint2: CGPoint(x: 0, y: bounds.height * 0.3)
)
path.close()
maskLayer.frame = bounds
maskLayer.path = path.cgPath
layer.mask = maskLayer
layer.cornerRadius = PlayerContainerView.cornerRadius
layer.maskedCorners = [.layerMaxXMaxYCorner]
layer.cornerCurve = .continuous
}
}
}
extension PlayerContainerView.MediaTypeIndicotorView {
private func _init() {
backgroundColor = Asset.Colors.Background.mediaTypeIndicotor.color
layoutMargins = UIEdgeInsets(top: 3, left: 13, bottom: 0, right: 6)
addSubview(label)
NSLayoutConstraint.activate([
label.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor),
label.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
label.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
label.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor),
])
}
private static func roundedFont(weight: UIFont.Weight,fontSize: CGFloat) -> UIFont {
let systemFont = UIFont.systemFont(ofSize: fontSize, weight: weight)
guard let descriptor = systemFont.fontDescriptor.withDesign(.rounded) else { return systemFont }
let roundedFont = UIFont(descriptor: descriptor, size: fontSize)
return roundedFont
}
func setMediaKind(kind: VideoPlayerViewModel.Kind) {
let fontSize: CGFloat = 18
switch kind {
case .gif:
label.font = PlayerContainerView.MediaTypeIndicotorView.roundedFont(weight: .heavy, fontSize: fontSize)
label.text = "GIF"
case .video:
let configuration = UIImage.SymbolConfiguration(font: PlayerContainerView.MediaTypeIndicotorView.roundedFont(weight: .regular, fontSize: fontSize))
let image = UIImage(systemName: "video.fill", withConfiguration: configuration)!
let attachment = NSTextAttachment()
attachment.image = image.withTintColor(.white)
label.attributedText = NSAttributedString(attachment: attachment)
}
}
}
#if canImport(SwiftUI) && DEBUG
import SwiftUI
struct PlayerContainerViewMediaTypeIndicotorView_Previews: PreviewProvider {
static var previews: some View {
Group {
UIViewPreview(width: 47) {
let view = PlayerContainerView.MediaTypeIndicotorView()
view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
view.heightAnchor.constraint(equalToConstant: 25),
view.widthAnchor.constraint(equalToConstant: 47),
])
view.setMediaKind(kind: .gif)
return view
}
.previewLayout(.fixed(width: 47, height: 25))
UIViewPreview(width: 47) {
let view = PlayerContainerView.MediaTypeIndicotorView()
view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
view.heightAnchor.constraint(equalToConstant: 25),
view.widthAnchor.constraint(equalToConstant: 47),
])
view.setMediaKind(kind: .video)
return view
}
.previewLayout(.fixed(width: 47, height: 25))
}
}
}
#endif

View File

@ -0,0 +1,158 @@
//
// PlayerContainerView.swift
// Mastodon
//
// Created by xiaojian sun on 2021/3/10.
//
import AVKit
import UIKit
protocol PlayerContainerViewDelegate: class {
func playerContainerView(_ playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
}
final class PlayerContainerView: UIView {
static let cornerRadius: CGFloat = 8
private let container = UIView()
private let touchBlockingView = TouchBlockingView()
private var containerHeightLayoutConstraint: NSLayoutConstraint!
let contentWarningOverlayView: ContentWarningOverlayView = {
let contentWarningOverlayView = ContentWarningOverlayView()
return contentWarningOverlayView
}()
let playerViewController = AVPlayerViewController()
let mediaTypeIndicotorView = MediaTypeIndicotorView()
let mediaTypeIndicotorViewInContentWarningOverlay = MediaTypeIndicotorView()
weak var delegate: PlayerContainerViewDelegate?
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension PlayerContainerView {
private func _init() {
container.translatesAutoresizingMaskIntoConstraints = false
addSubview(container)
containerHeightLayoutConstraint = container.heightAnchor.constraint(equalToConstant: 162).priority(.required - 1)
NSLayoutConstraint.activate([
container.topAnchor.constraint(equalTo: topAnchor),
container.leadingAnchor.constraint(equalTo: leadingAnchor),
trailingAnchor.constraint(equalTo: container.trailingAnchor),
bottomAnchor.constraint(equalTo: container.bottomAnchor),
containerHeightLayoutConstraint,
])
// will not influence full-screen playback
playerViewController.view.layer.masksToBounds = true
playerViewController.view.layer.cornerRadius = PlayerContainerView.cornerRadius
playerViewController.view.layer.cornerCurve = .continuous
// mediaType
mediaTypeIndicotorView.translatesAutoresizingMaskIntoConstraints = false
playerViewController.contentOverlayView!.addSubview(mediaTypeIndicotorView)
NSLayoutConstraint.activate([
mediaTypeIndicotorView.bottomAnchor.constraint(equalTo: playerViewController.contentOverlayView!.bottomAnchor),
mediaTypeIndicotorView.rightAnchor.constraint(equalTo: playerViewController.contentOverlayView!.rightAnchor),
mediaTypeIndicotorView.heightAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.height).priority(.defaultHigh),
mediaTypeIndicotorView.widthAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.width).priority(.defaultHigh),
])
addSubview(contentWarningOverlayView)
NSLayoutConstraint.activate([
contentWarningOverlayView.topAnchor.constraint(equalTo: topAnchor),
contentWarningOverlayView.leadingAnchor.constraint(equalTo: leadingAnchor),
contentWarningOverlayView.trailingAnchor.constraint(equalTo: trailingAnchor),
contentWarningOverlayView.bottomAnchor.constraint(equalTo: bottomAnchor)
])
contentWarningOverlayView.delegate = self
mediaTypeIndicotorViewInContentWarningOverlay.translatesAutoresizingMaskIntoConstraints = false
contentWarningOverlayView.addSubview(mediaTypeIndicotorViewInContentWarningOverlay)
NSLayoutConstraint.activate([
mediaTypeIndicotorViewInContentWarningOverlay.bottomAnchor.constraint(equalTo: contentWarningOverlayView.bottomAnchor),
mediaTypeIndicotorViewInContentWarningOverlay.rightAnchor.constraint(equalTo: contentWarningOverlayView.rightAnchor),
mediaTypeIndicotorViewInContentWarningOverlay.heightAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.height).priority(.defaultHigh),
mediaTypeIndicotorViewInContentWarningOverlay.widthAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.width).priority(.defaultHigh),
])
}
}
// MARK: - ContentWarningOverlayViewDelegate
extension PlayerContainerView: ContentWarningOverlayViewDelegate {
func contentWarningOverlayViewDidPressed(_ contentWarningOverlayView: ContentWarningOverlayView) {
delegate?.playerContainerView(self, contentWarningOverlayViewDidPressed: contentWarningOverlayView)
}
}
extension PlayerContainerView {
func reset() {
// note: set playerViewController.player pause() and nil in data source configuration process make reloadData not break playing
playerViewController.willMove(toParent: nil)
playerViewController.view.removeFromSuperview()
playerViewController.removeFromParent()
container.subviews.forEach { subview in
subview.removeFromSuperview()
}
}
func setupPlayer(aspectRatio: CGSize, maxSize: CGSize, parent: UIViewController?) -> AVPlayerViewController {
reset()
touchBlockingView.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(touchBlockingView)
NSLayoutConstraint.activate([
touchBlockingView.topAnchor.constraint(equalTo: container.topAnchor),
touchBlockingView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
touchBlockingView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
])
let rect = AVMakeRect(
aspectRatio: aspectRatio,
insideRect: CGRect(origin: .zero, size: maxSize)
)
parent?.addChild(playerViewController)
playerViewController.view.translatesAutoresizingMaskIntoConstraints = false
touchBlockingView.addSubview(playerViewController.view)
parent.flatMap { playerViewController.didMove(toParent: $0) }
NSLayoutConstraint.activate([
playerViewController.view.topAnchor.constraint(equalTo: touchBlockingView.topAnchor),
playerViewController.view.leadingAnchor.constraint(equalTo: touchBlockingView.leadingAnchor),
playerViewController.view.trailingAnchor.constraint(equalTo: touchBlockingView.trailingAnchor),
playerViewController.view.bottomAnchor.constraint(equalTo: touchBlockingView.bottomAnchor),
touchBlockingView.widthAnchor.constraint(equalToConstant: floor(rect.width)).priority(.required - 1),
])
containerHeightLayoutConstraint.constant = floor(rect.height)
containerHeightLayoutConstraint.isActive = true
bringSubviewToFront(mediaTypeIndicotorView)
return playerViewController
}
func setMediaKind(kind: VideoPlayerViewModel.Kind) {
mediaTypeIndicotorView.setMediaKind(kind: kind)
mediaTypeIndicotorViewInContentWarningOverlay.setMediaKind(kind: kind)
}
func setMediaIndicator(isHidden: Bool) {
mediaTypeIndicotorView.alpha = isHidden ? 0 : 1
mediaTypeIndicotorViewInContentWarningOverlay.alpha = isHidden ? 0 : 1
}
}

View File

@ -0,0 +1,34 @@
//
// TouchBlockingView.swift
// Mastodon
//
// Created by xiaojian sun on 2021/3/10.
//
import UIKit
final class TouchBlockingView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension TouchBlockingView {
private func _init() {
isUserInteractionEnabled = true
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
// Blocking responder chain by not call super
// The subviews in this view will received touch event but superview not
}
}

View File

@ -0,0 +1,92 @@
//
// ContentWarningOverlayView.swift
// Mastodon
//
// Created by sxiaojian on 2021/3/11.
//
import os.log
import Foundation
import UIKit
protocol ContentWarningOverlayViewDelegate: class {
func contentWarningOverlayViewDidPressed(_ contentWarningOverlayView: ContentWarningOverlayView)
}
class ContentWarningOverlayView: UIView {
static let cornerRadius: CGFloat = 4
static let blurVisualEffect = UIBlurEffect(style: .systemUltraThinMaterial)
let blurVisualEffectView = UIVisualEffectView(effect: ContentWarningOverlayView.blurVisualEffect)
let vibrancyVisualEffectView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: ContentWarningOverlayView.blurVisualEffect))
let contentWarningLabel: UILabel = {
let label = UILabel()
label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15))
label.text = L10n.Common.Controls.Status.mediaContentWarning
label.textAlignment = .center
return label
}()
let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer
weak var delegate: ContentWarningOverlayViewDelegate?
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension ContentWarningOverlayView {
private func _init() {
backgroundColor = .clear
translatesAutoresizingMaskIntoConstraints = false
// add blur visual effect view in the setup method
blurVisualEffectView.layer.masksToBounds = true
blurVisualEffectView.layer.cornerRadius = ContentWarningOverlayView.cornerRadius
blurVisualEffectView.layer.cornerCurve = .continuous
vibrancyVisualEffectView.translatesAutoresizingMaskIntoConstraints = false
blurVisualEffectView.contentView.addSubview(vibrancyVisualEffectView)
NSLayoutConstraint.activate([
vibrancyVisualEffectView.topAnchor.constraint(equalTo: blurVisualEffectView.topAnchor),
vibrancyVisualEffectView.leadingAnchor.constraint(equalTo: blurVisualEffectView.leadingAnchor),
vibrancyVisualEffectView.trailingAnchor.constraint(equalTo: blurVisualEffectView.trailingAnchor),
vibrancyVisualEffectView.bottomAnchor.constraint(equalTo: blurVisualEffectView.bottomAnchor),
])
contentWarningLabel.translatesAutoresizingMaskIntoConstraints = false
vibrancyVisualEffectView.contentView.addSubview(contentWarningLabel)
NSLayoutConstraint.activate([
contentWarningLabel.leadingAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.layoutMarginsGuide.leadingAnchor),
contentWarningLabel.trailingAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.layoutMarginsGuide.trailingAnchor),
contentWarningLabel.centerYAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.centerYAnchor),
])
blurVisualEffectView.translatesAutoresizingMaskIntoConstraints = false
addSubview(blurVisualEffectView)
NSLayoutConstraint.activate([
blurVisualEffectView.topAnchor.constraint(equalTo: topAnchor),
blurVisualEffectView.leadingAnchor.constraint(equalTo: leadingAnchor),
blurVisualEffectView.trailingAnchor.constraint(equalTo: trailingAnchor),
blurVisualEffectView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
tapGestureRecognizer.addTarget(self, action: #selector(ContentWarningOverlayView.tapGestureRecognizerHandler(_:)))
addGestureRecognizer(tapGestureRecognizer)
}
}
extension ContentWarningOverlayView {
@objc private func tapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
delegate?.contentWarningOverlayViewDidPressed(self)
}
}

View File

@ -13,16 +13,22 @@ import AlamofireImage
protocol StatusViewDelegate: class {
func statusView(_ statusView: StatusView, contentWarningActionButtonPressed button: UIButton)
func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton)
}
final class StatusView: UIView {
var statusPollTableViewHeightObservation: NSKeyValueObservation?
static let avatarImageSize = CGSize(width: 42, height: 42)
static let avatarImageCornerRadius: CGFloat = 4
static let contentWarningBlurRadius: CGFloat = 12
weak var delegate: StatusViewDelegate?
var isStatusTextSensitive = false
var pollTableViewDataSource: UITableViewDiffableDataSource<PollSection, PollItem>?
var pollTableViewHeightLaoutConstraint: NSLayoutConstraint!
let headerContainerStackView = UIStackView()
@ -43,7 +49,7 @@ final class StatusView: UIView {
let label = UILabel()
label.font = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: .systemFont(ofSize: 13, weight: .medium))
label.textColor = Asset.Colors.Label.secondary.color
label.text = "Bob boosted"
label.text = "Bob reblogged"
return label
}()
@ -55,6 +61,7 @@ final class StatusView: UIView {
button.setImage(placeholderImage, for: .normal)
return button
}()
let avatarStackedContainerButton: AvatarStackContainerButton = AvatarStackContainerButton()
let nameLabel: UILabel = {
let label = UILabel()
@ -99,7 +106,49 @@ final class StatusView: UIView {
button.setTitle(L10n.Common.Controls.Status.showPost, for: .normal)
return button
}()
let statusMosaicImageView = MosaicImageViewContainer()
let statusMosaicImageViewContainer = MosaicImageViewContainer()
let pollTableView: PollTableView = {
let tableView = PollTableView(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
tableView.register(PollOptionTableViewCell.self, forCellReuseIdentifier: String(describing: PollOptionTableViewCell.self))
tableView.isScrollEnabled = false
tableView.separatorStyle = .none
tableView.backgroundColor = .clear
return tableView
}()
let pollStatusStackView = UIStackView()
let pollVoteCountLabel: UILabel = {
let label = UILabel()
label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 12, weight: .regular))
label.textColor = Asset.Colors.Label.secondary.color
label.text = L10n.Common.Controls.Status.Poll.VoteCount.single(0)
return label
}()
let pollStatusDotLabel: UILabel = {
let label = UILabel()
label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 12, weight: .regular))
label.textColor = Asset.Colors.Label.secondary.color
label.text = " · "
return label
}()
let pollCountdownLabel: UILabel = {
let label = UILabel()
label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 12, weight: .regular))
label.textColor = Asset.Colors.Label.secondary.color
label.text = L10n.Common.Controls.Status.Poll.timeLeft("6 hours")
return label
}()
let pollVoteButton: UIButton = {
let button = HitTestExpandedButton()
button.titleLabel?.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 14, weight: .semibold))
button.setTitle(L10n.Common.Controls.Status.Poll.vote, for: .normal)
button.setTitleColor(Asset.Colors.Button.highlight.color, for: .normal)
button.setTitleColor(Asset.Colors.Button.highlight.color.withAlphaComponent(0.8), for: .highlighted)
button.setTitleColor(Asset.Colors.Button.disabled.color, for: .disabled)
button.isEnabled = false
return button
}()
// do not use visual effect view due to we blur text only without background
let contentWarningBlurContentImageView: UIImageView = {
@ -109,6 +158,12 @@ final class StatusView: UIView {
return imageView
}()
let playerContainerView = PlayerContainerView()
let audioView: AudioContainerView = {
let audioView = AudioContainerView()
return audioView
}()
let actionToolbarContainer: ActionToolbarContainer = {
let actionToolbarContainer = ActionToolbarContainer()
actionToolbarContainer.configure(for: .inline)
@ -136,6 +191,10 @@ final class StatusView: UIView {
drawContentWarningImageView()
}
}
deinit {
statusPollTableViewHeightObservation = nil
}
}
@ -183,6 +242,14 @@ extension StatusView {
avatarButton.trailingAnchor.constraint(equalTo: avatarView.trailingAnchor),
avatarButton.bottomAnchor.constraint(equalTo: avatarView.bottomAnchor),
])
avatarStackedContainerButton.translatesAutoresizingMaskIntoConstraints = false
avatarView.addSubview(avatarStackedContainerButton)
NSLayoutConstraint.activate([
avatarStackedContainerButton.topAnchor.constraint(equalTo: avatarView.topAnchor),
avatarStackedContainerButton.leadingAnchor.constraint(equalTo: avatarView.leadingAnchor),
avatarStackedContainerButton.trailingAnchor.constraint(equalTo: avatarView.trailingAnchor),
avatarStackedContainerButton.bottomAnchor.constraint(equalTo: avatarView.bottomAnchor),
])
// author meta container: [title container | subtitle container]
let authorMetaContainerStackView = UIStackView()
@ -222,7 +289,7 @@ extension StatusView {
subtitleContainerStackView.axis = .horizontal
subtitleContainerStackView.addArrangedSubview(usernameLabel)
// status container: [status | image / video | audio]
// status container: [status | image / video | audio | poll | poll status]
containerStackView.addArrangedSubview(statusContainerStackView)
statusContainerStackView.axis = .vertical
statusContainerStackView.spacing = 10
@ -236,6 +303,7 @@ extension StatusView {
activeTextLabel.trailingAnchor.constraint(equalTo: statusTextContainerView.trailingAnchor),
statusTextContainerView.bottomAnchor.constraint(greaterThanOrEqualTo: activeTextLabel.bottomAnchor),
])
activeTextLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical)
contentWarningBlurContentImageView.translatesAutoresizingMaskIntoConstraints = false
statusTextContainerView.addSubview(contentWarningBlurContentImageView)
NSLayoutConstraint.activate([
@ -257,20 +325,66 @@ extension StatusView {
])
statusContentWarningContainerStackView.addArrangedSubview(contentWarningTitle)
statusContentWarningContainerStackView.addArrangedSubview(contentWarningActionButton)
statusContainerStackView.addArrangedSubview(statusMosaicImageView)
statusContainerStackView.addArrangedSubview(statusMosaicImageViewContainer)
pollTableView.translatesAutoresizingMaskIntoConstraints = false
statusContainerStackView.addArrangedSubview(pollTableView)
pollTableViewHeightLaoutConstraint = pollTableView.heightAnchor.constraint(equalToConstant: 44.0).priority(.required - 1)
NSLayoutConstraint.activate([
pollTableViewHeightLaoutConstraint,
])
statusPollTableViewHeightObservation = pollTableView.observe(\.contentSize, options: .new, changeHandler: { [weak self] tableView, _ in
guard let self = self else { return }
guard self.pollTableView.contentSize.height != .zero else {
self.pollTableViewHeightLaoutConstraint.constant = 44
return
}
self.pollTableViewHeightLaoutConstraint.constant = self.pollTableView.contentSize.height
})
statusContainerStackView.addArrangedSubview(pollStatusStackView)
pollStatusStackView.axis = .horizontal
pollStatusStackView.addArrangedSubview(pollVoteCountLabel)
pollStatusStackView.addArrangedSubview(pollStatusDotLabel)
pollStatusStackView.addArrangedSubview(pollCountdownLabel)
pollStatusStackView.addArrangedSubview(pollVoteButton)
pollVoteCountLabel.setContentHuggingPriority(.defaultHigh + 2, for: .horizontal)
pollStatusDotLabel.setContentHuggingPriority(.defaultHigh + 1, for: .horizontal)
pollCountdownLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
pollVoteButton.setContentHuggingPriority(.defaultHigh + 3, for: .horizontal)
// audio
audioView.translatesAutoresizingMaskIntoConstraints = false
statusContainerStackView.addArrangedSubview(audioView)
NSLayoutConstraint.activate([
audioView.leadingAnchor.constraint(equalTo: statusTextContainerView.leadingAnchor),
audioView.trailingAnchor.constraint(equalTo: statusTextContainerView.trailingAnchor),
audioView.heightAnchor.constraint(equalToConstant: 44)
])
// video gif
statusContainerStackView.addArrangedSubview(playerContainerView)
// action toolbar container
containerStackView.addArrangedSubview(actionToolbarContainer)
actionToolbarContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
headerContainerStackView.isHidden = true
statusMosaicImageView.isHidden = true
statusMosaicImageViewContainer.isHidden = true
pollTableView.isHidden = true
pollStatusStackView.isHidden = true
audioView.isHidden = true
playerContainerView.isHidden = true
avatarStackedContainerButton.isHidden = true
contentWarningBlurContentImageView.isHidden = true
statusContentWarningContainerStackView.isHidden = true
statusContentWarningContainerStackViewBottomLayoutConstraint.isActive = false
playerContainerView.delegate = self
contentWarningActionButton.addTarget(self, action: #selector(StatusView.contentWarningActionButtonPressed(_:)), for: .touchUpInside)
pollVoteButton.addTarget(self, action: #selector(StatusView.pollVoteButtonPressed(_:)), for: .touchUpInside)
}
}
@ -306,20 +420,33 @@ extension StatusView {
}
extension StatusView {
@objc private func contentWarningActionButtonPressed(_ sender: UIButton) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
delegate?.statusView(self, contentWarningActionButtonPressed: sender)
}
@objc private func pollVoteButtonPressed(_ sender: UIButton) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
delegate?.statusView(self, pollVoteButtonPressed: sender)
}
}
// MARK: - PlayerContainerViewDelegate
extension StatusView: PlayerContainerViewDelegate {
func playerContainerView(_ playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) {
delegate?.statusView(self, playerContainerView: playerContainerView, contentWarningOverlayViewDidPressed: contentWarningOverlayView)
}
}
// MARK: - AvatarConfigurableView
extension StatusView: AvatarConfigurableView {
static var configurableAvatarImageSize: CGSize { return Self.avatarImageSize }
static var configurableAvatarImageCornerRadius: CGFloat { return 4 }
var configurableAvatarImageView: UIImageView? { return nil }
var configurableAvatarButton: UIButton? { return avatarButton }
var configurableVerifiedBadgeImageView: UIImageView? { nil }
}
#if canImport(SwiftUI) && DEBUG
@ -328,6 +455,7 @@ import SwiftUI
struct StatusView_Previews: PreviewProvider {
static let avatarFlora = UIImage(named: "tiraya-adam")
static let avatarMarkus = UIImage(named: "markus-spiske")
static var previews: some View {
Group {
@ -342,6 +470,49 @@ struct StatusView_Previews: PreviewProvider {
return statusView
}
.previewLayout(.fixed(width: 375, height: 200))
.previewDisplayName("Normal")
UIViewPreview(width: 375) {
let statusView = StatusView()
statusView.headerContainerStackView.isHidden = false
statusView.avatarButton.isHidden = true
statusView.avatarStackedContainerButton.isHidden = false
statusView.avatarStackedContainerButton.topLeadingAvatarStackedImageView.configure(
with: AvatarConfigurableViewConfiguration(
avatarImageURL: nil,
placeholderImage: avatarFlora
)
)
statusView.avatarStackedContainerButton.bottomTrailingAvatarStackedImageView.configure(
with: AvatarConfigurableViewConfiguration(
avatarImageURL: nil,
placeholderImage: avatarMarkus
)
)
return statusView
}
.previewLayout(.fixed(width: 375, height: 200))
.previewDisplayName("Reblog")
UIViewPreview(width: 375) {
let statusView = StatusView(frame: CGRect(x: 0, y: 0, width: 375, height: 500))
statusView.configure(
with: AvatarConfigurableViewConfiguration(
avatarImageURL: nil,
placeholderImage: avatarFlora
)
)
statusView.headerContainerStackView.isHidden = false
let images = MosaicImageView_Previews.images
let imageViews = statusView.statusMosaicImageViewContainer.setupImageViews(count: 4, maxHeight: 162)
for (i, imageView) in imageViews.enumerated() {
imageView.image = images[i]
}
statusView.statusMosaicImageViewContainer.isHidden = false
statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isHidden = true
statusView.isStatusTextSensitive = false
return statusView
}
.previewLayout(.fixed(width: 375, height: 380))
.previewDisplayName("Image Meida")
UIViewPreview(width: 375) {
let statusView = StatusView(frame: CGRect(x: 0, y: 0, width: 375, height: 500))
statusView.configure(
@ -357,14 +528,15 @@ struct StatusView_Previews: PreviewProvider {
statusView.drawContentWarningImageView()
statusView.updateContentWarningDisplay(isHidden: false)
let images = MosaicImageView_Previews.images
let imageViews = statusView.statusMosaicImageView.setupImageViews(count: 4, maxHeight: 162)
let imageViews = statusView.statusMosaicImageViewContainer.setupImageViews(count: 4, maxHeight: 162)
for (i, imageView) in imageViews.enumerated() {
imageView.image = images[i]
}
statusView.statusMosaicImageView.isHidden = false
statusView.statusMosaicImageViewContainer.isHidden = false
return statusView
}
.previewLayout(.fixed(width: 375, height: 380))
.previewDisplayName("Content Sensitive")
}
}

View File

@ -0,0 +1,177 @@
//
// AvatarStackContainerButton.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-10.
//
import os.log
import UIKit
final class AvatarStackedImageView: UIImageView { }
// MARK: - AvatarConfigurableView
extension AvatarStackedImageView: AvatarConfigurableView {
static var configurableAvatarImageSize: CGSize { CGSize(width: 28, height: 28) }
static var configurableAvatarImageCornerRadius: CGFloat { 4 }
var configurableAvatarImageView: UIImageView? { self }
var configurableAvatarButton: UIButton? { nil }
}
final class AvatarStackContainerButton: UIControl {
static let containerSize = CGSize(width: 42, height: 42)
static let maskOffset: CGFloat = 2
// UIControl.Event - Application: 0x0F000000
static let primaryAction = UIControl.Event(rawValue: 1 << 25) // 0x01000000
var primaryActionState: UIControl.State = .normal
let topLeadingAvatarStackedImageView = AvatarStackedImageView()
let bottomTrailingAvatarStackedImageView = AvatarStackedImageView()
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension AvatarStackContainerButton {
private func _init() {
topLeadingAvatarStackedImageView.translatesAutoresizingMaskIntoConstraints = false
addSubview(topLeadingAvatarStackedImageView)
NSLayoutConstraint.activate([
topLeadingAvatarStackedImageView.topAnchor.constraint(equalTo: topAnchor),
topLeadingAvatarStackedImageView.leadingAnchor.constraint(equalTo: leadingAnchor),
topLeadingAvatarStackedImageView.widthAnchor.constraint(equalToConstant: AvatarStackedImageView.configurableAvatarImageSize.width).priority(.defaultHigh),
topLeadingAvatarStackedImageView.heightAnchor.constraint(equalToConstant: AvatarStackedImageView.configurableAvatarImageSize.height).priority(.defaultHigh),
])
bottomTrailingAvatarStackedImageView.translatesAutoresizingMaskIntoConstraints = false
addSubview(bottomTrailingAvatarStackedImageView)
NSLayoutConstraint.activate([
bottomTrailingAvatarStackedImageView.bottomAnchor.constraint(equalTo: bottomAnchor),
bottomTrailingAvatarStackedImageView.trailingAnchor.constraint(equalTo: trailingAnchor),
bottomTrailingAvatarStackedImageView.widthAnchor.constraint(equalToConstant: AvatarStackedImageView.configurableAvatarImageSize.width).priority(.defaultHigh),
bottomTrailingAvatarStackedImageView.heightAnchor.constraint(equalToConstant: AvatarStackedImageView.configurableAvatarImageSize.height).priority(.defaultHigh),
])
// mask topLeadingAvatarStackedImageView
let offset: CGFloat = 2
let path: CGPath = {
let path = CGMutablePath()
path.addRect(CGRect(origin: .zero, size: AvatarStackedImageView.configurableAvatarImageSize))
let mirrorScale: CGFloat = UIApplication.shared.userInterfaceLayoutDirection == .rightToLeft ? -1 : 1
path.addPath(UIBezierPath(
roundedRect: CGRect(
x: mirrorScale * (AvatarStackContainerButton.containerSize.width - AvatarStackedImageView.configurableAvatarImageSize.width - offset),
y: AvatarStackContainerButton.containerSize.height - AvatarStackedImageView.configurableAvatarImageSize.height - offset,
width: AvatarStackedImageView.configurableAvatarImageSize.width,
height: AvatarStackedImageView.configurableAvatarImageSize.height
),
cornerRadius: AvatarStackedImageView.configurableAvatarImageCornerRadius
).cgPath)
return path
}()
let maskShapeLayer = CAShapeLayer()
maskShapeLayer.backgroundColor = UIColor.black.cgColor
maskShapeLayer.fillRule = .evenOdd
maskShapeLayer.path = path
topLeadingAvatarStackedImageView.layer.mask = maskShapeLayer
topLeadingAvatarStackedImageView.image = UIImage.placeholder(color: .systemFill)
bottomTrailingAvatarStackedImageView.image = UIImage.placeholder(color: .systemFill)
}
override var intrinsicContentSize: CGSize {
return AvatarStackContainerButton.containerSize
}
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
defer { updateAppearance() }
updateState(touch: touch, event: event)
return super.beginTracking(touch, with: event)
}
override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
defer { updateAppearance() }
updateState(touch: touch, event: event)
return super.continueTracking(touch, with: event)
}
override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
defer { updateAppearance() }
resetState()
if let touch = touch {
if AvatarStackContainerButton.isTouching(touch, view: self, event: event) {
sendActions(for: AvatarStackContainerButton.primaryAction)
} else {
// do nothing
}
}
super.endTracking(touch, with: event)
}
override func cancelTracking(with event: UIEvent?) {
defer { updateAppearance() }
resetState()
super.cancelTracking(with: event)
}
}
extension AvatarStackContainerButton {
private func updateAppearance() {
topLeadingAvatarStackedImageView.alpha = primaryActionState.contains(.highlighted) ? 0.6 : 1.0
bottomTrailingAvatarStackedImageView.alpha = primaryActionState.contains(.highlighted) ? 0.6 : 1.0
}
private static func isTouching(_ touch: UITouch, view: UIView, event: UIEvent?) -> Bool {
let location = touch.location(in: view)
return view.point(inside: location, with: event)
}
private func resetState() {
primaryActionState = .normal
}
private func updateState(touch: UITouch, event: UIEvent?) {
primaryActionState = AvatarStackContainerButton.isTouching(touch, view: self, event: event) ? .highlighted : .normal
}
}
#if canImport(SwiftUI) && DEBUG
import SwiftUI
struct AvatarStackContainerButton_Previews: PreviewProvider {
static var previews: some View {
UIViewPreview(width: 42) {
let avatarStackContainerButton = AvatarStackContainerButton()
avatarStackContainerButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
avatarStackContainerButton.widthAnchor.constraint(equalToConstant: 42),
avatarStackContainerButton.heightAnchor.constraint(equalToConstant: 42),
])
return avatarStackContainerButton
}
.previewLayout(.fixed(width: 42, height: 42))
}
}
#endif

View File

@ -0,0 +1,174 @@
//
// StripProgressView.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-3.
//
import os.log
import UIKit
import Combine
private final class StripProgressLayer: CALayer {
static let progressAnimationKey = "progressAnimationKey"
static let progressKey = "progress"
var tintColor: UIColor = .black
@NSManaged var progress: CGFloat
override class func needsDisplay(forKey key: String) -> Bool {
switch key {
case StripProgressLayer.progressKey:
return true
default:
return super.needsDisplay(forKey: key)
}
}
override func display() {
let progress: CGFloat = {
guard animation(forKey: StripProgressLayer.progressAnimationKey) != nil else {
return self.progress
}
return presentation()?.progress ?? self.progress
}()
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: progress: %.2f", ((#file as NSString).lastPathComponent), #line, #function, progress)
UIGraphicsBeginImageContextWithOptions(bounds.size, false, 0)
guard let context = UIGraphicsGetCurrentContext() else {
assertionFailure()
return
}
context.clear(bounds)
var rect = bounds
let newWidth = CGFloat(progress) * rect.width
let widthChanged = rect.width - newWidth
rect.size.width = newWidth
switch UIApplication.shared.userInterfaceLayoutDirection {
case .rightToLeft:
rect.origin.x += widthChanged
default:
break
}
let path = UIBezierPath(rect: rect)
context.setFillColor(tintColor.cgColor)
context.addPath(path.cgPath)
context.fillPath()
contents = UIGraphicsGetImageFromCurrentImageContext()?.cgImage
UIGraphicsEndImageContext()
}
}
final class StripProgressView: UIView {
var disposeBag = Set<AnyCancellable>()
private let stripProgressLayer: StripProgressLayer = {
let layer = StripProgressLayer()
return layer
}()
override var tintColor: UIColor! {
didSet {
stripProgressLayer.tintColor = tintColor
setNeedsDisplay()
}
}
func setProgress(_ progress: CGFloat, animated: Bool) {
stripProgressLayer.removeAnimation(forKey: StripProgressLayer.progressAnimationKey)
if animated {
let animation = CABasicAnimation(keyPath: StripProgressLayer.progressKey)
animation.fromValue = stripProgressLayer.presentation()?.progress ?? stripProgressLayer.progress
animation.toValue = progress
animation.duration = 0.33
animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
animation.isRemovedOnCompletion = true
stripProgressLayer.add(animation, forKey: StripProgressLayer.progressAnimationKey)
stripProgressLayer.progress = progress
} else {
stripProgressLayer.progress = progress
stripProgressLayer.setNeedsDisplay()
}
}
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension StripProgressView {
private func _init() {
layer.addSublayer(stripProgressLayer)
updateLayerPath()
}
override func layoutSubviews() {
super.layoutSubviews()
updateLayerPath()
}
}
extension StripProgressView {
private func updateLayerPath() {
guard bounds != .zero else { return }
stripProgressLayer.frame = bounds
stripProgressLayer.tintColor = tintColor
stripProgressLayer.setNeedsDisplay()
}
}
#if DEBUG
import SwiftUI
struct VoteProgressStripView_Previews: PreviewProvider {
static var previews: some View {
Group {
UIViewPreview() {
StripProgressView()
}
.frame(width: 100, height: 44)
.padding()
.background(Color.black)
.previewLayout(.sizeThatFits)
UIViewPreview() {
let bar = StripProgressView()
bar.tintColor = .white
bar.setProgress(0.5, animated: false)
return bar
}
.frame(width: 100, height: 44)
.padding()
.background(Color.black)
.previewLayout(.sizeThatFits)
UIViewPreview() {
let bar = StripProgressView()
bar.tintColor = .white
bar.setProgress(1.0, animated: false)
return bar
}
.frame(width: 100, height: 44)
.padding()
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}
}
#endif

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