feat: [WIP] add profile scene

This commit is contained in:
CMK 2021-04-01 14:39:15 +08:00
parent d9533deccf
commit 43ee11b863
105 changed files with 4450 additions and 613 deletions

View File

@ -1,11 +1,11 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17709" systemVersion="20D80" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17709" systemVersion="20D91" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Application" representedClassName=".Application" syncable="YES">
<attribute name="identifier" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String"/>
<attribute name="vapidKey" optional="YES" attributeType="String"/>
<attribute name="website" optional="YES" attributeType="String"/>
<relationship name="toots" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="application" inverseEntity="Toot"/>
<relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="application" inverseEntity="Status"/>
</entity>
<entity name="Attachment" representedClassName=".Attachment" syncable="YES">
<attribute name="blurhash" optional="YES" attributeType="String"/>
@ -22,7 +22,7 @@
<attribute name="typeRaw" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="url" optional="YES" attributeType="String"/>
<relationship name="toot" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="mediaAttachments" inverseEntity="Toot"/>
<relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="mediaAttachments" inverseEntity="Status"/>
</entity>
<entity name="Emoji" representedClassName=".Emoji" syncable="YES">
<attribute name="category" optional="YES" attributeType="String"/>
@ -32,7 +32,7 @@
<attribute name="staticURL" attributeType="String"/>
<attribute name="url" attributeType="String"/>
<attribute name="visibleInPicker" attributeType="Boolean" usesScalarValueType="YES"/>
<relationship name="toot" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="emojis" inverseEntity="Toot"/>
<relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="emojis" inverseEntity="Status"/>
</entity>
<entity name="History" representedClassName=".History" syncable="YES">
<attribute name="accounts" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
@ -49,7 +49,7 @@
<attribute name="hasMore" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="identifier" attributeType="String"/>
<attribute name="userID" attributeType="String"/>
<relationship name="toot" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="homeTimelineIndexes" inverseEntity="Toot"/>
<relationship name="status" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="homeTimelineIndexes" inverseEntity="Status"/>
</entity>
<entity name="MastodonAuthentication" representedClassName=".MastodonAuthentication" syncable="YES">
<attribute name="activedAt" attributeType="Date" usesScalarValueType="NO"/>
@ -72,17 +72,38 @@
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="displayName" attributeType="String"/>
<attribute name="domain" attributeType="String"/>
<attribute name="followersCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="followingCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="header" attributeType="String"/>
<attribute name="headerStatic" optional="YES" attributeType="String"/>
<attribute name="id" attributeType="String"/>
<attribute name="identifier" attributeType="String"/>
<attribute name="note" optional="YES" attributeType="String"/>
<attribute name="statusesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="url" optional="YES" attributeType="String"/>
<attribute name="username" attributeType="String"/>
<relationship name="bookmarked" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="bookmarkedBy" inverseEntity="Toot"/>
<relationship name="favourite" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="favouritedBy" inverseEntity="Toot"/>
<relationship name="blocking" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="blockingBy" inverseEntity="MastodonUser"/>
<relationship name="blockingBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="blocking" inverseEntity="MastodonUser"/>
<relationship name="bookmarked" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="bookmarkedBy" inverseEntity="Status"/>
<relationship name="domainBlocking" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="domainBlockingBy" inverseEntity="MastodonUser"/>
<relationship name="domainBlockingBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="domainBlocking" inverseEntity="MastodonUser"/>
<relationship name="endorsed" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="endorsedBy" inverseEntity="MastodonUser"/>
<relationship name="endorsedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="endorsed" inverseEntity="MastodonUser"/>
<relationship name="favourite" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="favouritedBy" inverseEntity="Status"/>
<relationship name="following" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="followingBy" inverseEntity="MastodonUser"/>
<relationship name="followingBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="following" inverseEntity="MastodonUser"/>
<relationship name="followRequested" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="followRequestedBy" inverseEntity="MastodonUser"/>
<relationship name="followRequestedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="followRequested" inverseEntity="MastodonUser"/>
<relationship name="mastodonAuthentication" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonAuthentication" inverseName="user" inverseEntity="MastodonAuthentication"/>
<relationship name="muted" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="mutedBy" inverseEntity="Toot"/>
<relationship name="pinnedToot" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="pinnedBy" inverseEntity="Toot"/>
<relationship name="reblogged" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="rebloggedBy" inverseEntity="Toot"/>
<relationship name="toots" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="author" inverseEntity="Toot"/>
<relationship name="muted" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="mutedBy" inverseEntity="Status"/>
<relationship name="muting" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="mutingBy" inverseEntity="MastodonUser"/>
<relationship name="mutingBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="muting" inverseEntity="MastodonUser"/>
<relationship name="pinnedStatus" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="pinnedBy" inverseEntity="Status"/>
<relationship name="privateNotes" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="PrivateNote" inverseName="to" inverseEntity="PrivateNote"/>
<relationship name="privateNotesTo" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="PrivateNote" inverseName="from" inverseEntity="PrivateNote"/>
<relationship name="reblogged" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="rebloggedBy" inverseEntity="Status"/>
<relationship name="statuses" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="author" inverseEntity="Status"/>
<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>
@ -93,7 +114,7 @@
<attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="url" attributeType="String"/>
<attribute name="username" attributeType="String"/>
<relationship name="toot" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="mentions" inverseEntity="Toot"/>
<relationship name="status" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="mentions" inverseEntity="Status"/>
</entity>
<entity name="Poll" representedClassName=".Poll" syncable="YES">
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
@ -105,7 +126,7 @@
<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="status" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="poll" inverseEntity="Status"/>
<relationship name="votedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="votePolls" inverseEntity="MastodonUser"/>
</entity>
<entity name="PollOption" representedClassName=".PollOption" syncable="YES">
@ -117,15 +138,13 @@
<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"/>
<attribute name="name" attributeType="String"/>
<attribute name="url" attributeType="String"/>
<relationship name="histories" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="History" inverseName="tag" inverseEntity="History"/>
<relationship name="toot" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="tags" inverseEntity="Toot"/>
<entity name="PrivateNote" representedClassName="PrivateNote" syncable="YES">
<attribute name="note" optional="YES" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="from" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="privateNotesTo" inverseEntity="MastodonUser"/>
<relationship name="to" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="privateNotes" inverseEntity="MastodonUser"/>
</entity>
<entity name="Toot" representedClassName=".Toot" syncable="YES">
<entity name="Status" representedClassName=".Status" syncable="YES">
<attribute name="content" attributeType="String"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="deletedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
@ -145,23 +164,31 @@
<attribute name="uri" attributeType="String"/>
<attribute name="url" attributeType="String"/>
<attribute name="visibility" optional="YES" attributeType="String"/>
<relationship name="application" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Application" inverseName="toots" inverseEntity="Application"/>
<relationship name="author" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="toots" inverseEntity="MastodonUser"/>
<relationship name="application" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Application" inverseName="status" inverseEntity="Application"/>
<relationship name="author" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="statuses" inverseEntity="MastodonUser"/>
<relationship name="bookmarkedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="bookmarked" inverseEntity="MastodonUser"/>
<relationship name="emojis" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Emoji" inverseName="toot" inverseEntity="Emoji"/>
<relationship name="emojis" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Emoji" inverseName="status" inverseEntity="Emoji"/>
<relationship name="favouritedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="favourite" inverseEntity="MastodonUser"/>
<relationship name="homeTimelineIndexes" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="HomeTimelineIndex" inverseName="toot" inverseEntity="HomeTimelineIndex"/>
<relationship name="mediaAttachments" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Attachment" inverseName="toot" inverseEntity="Attachment"/>
<relationship name="mentions" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Mention" inverseName="toot" inverseEntity="Mention"/>
<relationship name="homeTimelineIndexes" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="HomeTimelineIndex" inverseName="status" inverseEntity="HomeTimelineIndex"/>
<relationship name="mediaAttachments" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Attachment" inverseName="status" inverseEntity="Attachment"/>
<relationship name="mentions" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Mention" inverseName="status" 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="pinnedBy" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="pinnedStatus" inverseEntity="MastodonUser"/>
<relationship name="poll" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="Poll" inverseName="status" inverseEntity="Poll"/>
<relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="reblogFrom" inverseEntity="Status"/>
<relationship name="reblogFrom" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="reblog" inverseEntity="Status"/>
<relationship name="rebloggedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="reblogged" inverseEntity="MastodonUser"/>
<relationship name="replyFrom" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="replyTo" inverseEntity="Toot"/>
<relationship name="replyTo" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="replyFrom" inverseEntity="Toot"/>
<relationship name="tags" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Tag" inverseName="toot" inverseEntity="Tag"/>
<relationship name="replyFrom" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="replyTo" inverseEntity="Status"/>
<relationship name="replyTo" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="replyFrom" inverseEntity="Status"/>
<relationship name="tags" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Tag" inverseName="statuses" inverseEntity="Tag"/>
</entity>
<entity name="Tag" representedClassName=".Tag" syncable="YES">
<attribute name="createAt" attributeType="Date" defaultDateTimeInterval="631123200" usesScalarValueType="NO"/>
<attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String"/>
<attribute name="url" attributeType="String"/>
<relationship name="histories" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="History" inverseName="tag" inverseEntity="History"/>
<relationship name="statuses" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="tags" inverseEntity="Status"/>
</entity>
<elements>
<element name="Application" positionX="160" positionY="192" width="128" height="104"/>
@ -170,11 +197,12 @@
<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="314"/>
<element name="MastodonUser" positionX="0" positionY="0" width="128" height="629"/>
<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="14"/>
<element name="Status" positionX="0" positionY="0" width="128" height="569"/>
<element name="PrivateNote" positionX="72" positionY="153" width="128" height="89"/>
</elements>
</model>

View File

@ -38,7 +38,7 @@ public final class CoreDataStack {
}()
static func persistentContainer() -> NSPersistentContainer {
let bundles = [Bundle(for: Toot.self)]
let bundles = [Bundle(for: Status.self)]
guard let managedObjectModel = NSManagedObjectModel.mergedModel(from: bundles) else {
fatalError("cannot locate bundles")
}

View File

@ -17,8 +17,8 @@ public final class Application: NSManagedObject {
@NSManaged public private(set) var website: String?
@NSManaged public private(set) var vapidKey: String?
// one-to-many relationship
@NSManaged public private(set) var toots: Set<Toot>
// one-to-one relationship
@NSManaged public private(set) var status: Status
}
public extension Application {

View File

@ -28,7 +28,7 @@ public final class Attachment: NSManagedObject {
@NSManaged public private(set) var index: NSNumber
// many-to-one relastionship
@NSManaged public private(set) var toot: Toot?
@NSManaged public private(set) var status: Status?
}

View File

@ -20,7 +20,7 @@ public final class Emoji: NSManagedObject {
@NSManaged public private(set) var category: String?
// many-to-one relationship
@NSManaged public private(set) var toot: Toot?
@NSManaged public private(set) var status: Status?
}
public extension Emoji {

View File

@ -22,7 +22,7 @@ final public class HomeTimelineIndex: NSManagedObject {
// many-to-one relationship
@NSManaged public private(set) var toot: Toot
@NSManaged public private(set) var status: Status
}
@ -32,16 +32,16 @@ extension HomeTimelineIndex {
public static func insert(
into context: NSManagedObjectContext,
property: Property,
toot: Toot
status: Status
) -> HomeTimelineIndex {
let index: HomeTimelineIndex = context.insertObject()
index.identifier = property.identifier
index.domain = property.domain
index.userID = property.userID
index.createdAt = toot.createdAt
index.createdAt = status.createdAt
index.toot = toot
index.status = status
return index
}

View File

@ -21,24 +21,44 @@ final public class MastodonUser: NSManagedObject {
@NSManaged public private(set) var displayName: String
@NSManaged public private(set) var avatar: String
@NSManaged public private(set) var avatarStatic: String?
@NSManaged public private(set) var header: String
@NSManaged public private(set) var headerStatic: String?
@NSManaged public private(set) var note: String?
@NSManaged public private(set) var url: String?
@NSManaged public private(set) var statusesCount: NSNumber
@NSManaged public private(set) var followingCount: NSNumber
@NSManaged public private(set) var followersCount: NSNumber
@NSManaged public private(set) var createdAt: Date
@NSManaged public private(set) var updatedAt: Date
// one-to-one relationship
@NSManaged public private(set) var pinnedToot: Toot?
@NSManaged public private(set) var pinnedStatus: Status?
@NSManaged public private(set) var mastodonAuthentication: MastodonAuthentication?
// one-to-many relationship
@NSManaged public private(set) var toots: Set<Toot>?
@NSManaged public private(set) var statuses: Set<Status>?
// many-to-many relationship
@NSManaged public private(set) var favourite: Set<Toot>?
@NSManaged public private(set) var reblogged: Set<Toot>?
@NSManaged public private(set) var muted: Set<Toot>?
@NSManaged public private(set) var bookmarked: Set<Toot>?
@NSManaged public private(set) var favourite: Set<Status>?
@NSManaged public private(set) var reblogged: Set<Status>?
@NSManaged public private(set) var muted: Set<Status>?
@NSManaged public private(set) var bookmarked: Set<Status>?
@NSManaged public private(set) var votePollOptions: Set<PollOption>?
@NSManaged public private(set) var votePolls: Set<Poll>?
// relationships
@NSManaged public private(set) var following: Set<MastodonUser>?
@NSManaged public private(set) var followingBy: Set<MastodonUser>?
@NSManaged public private(set) var followRequested: Set<MastodonUser>?
@NSManaged public private(set) var followRequestedBy: Set<MastodonUser>?
@NSManaged public private(set) var muting: Set<MastodonUser>?
@NSManaged public private(set) var mutingBy: Set<MastodonUser>?
@NSManaged public private(set) var blocking: Set<MastodonUser>?
@NSManaged public private(set) var blockingBy: Set<MastodonUser>?
@NSManaged public private(set) var endorsed: Set<MastodonUser>?
@NSManaged public private(set) var endorsedBy: Set<MastodonUser>?
@NSManaged public private(set) var domainBlocking: Set<MastodonUser>?
@NSManaged public private(set) var domainBlockingBy: Set<MastodonUser>?
}
@ -60,6 +80,16 @@ extension MastodonUser {
user.displayName = property.displayName
user.avatar = property.avatar
user.avatarStatic = property.avatarStatic
user.header = property.header
user.headerStatic = property.headerStatic
user.note = property.note
user.url = property.url
user.statusesCount = NSNumber(value: property.statusesCount)
user.followingCount = NSNumber(value: property.followingCount)
user.followersCount = NSNumber(value: property.followersCount)
// Mastodon do not provide relationship on the `Account`
// Update relationship via attribute updating interface
user.createdAt = property.createdAt
user.updatedAt = property.networkDate
@ -93,6 +123,107 @@ extension MastodonUser {
self.avatarStatic = avatarStatic
}
}
public func update(header: String) {
if self.header != header {
self.header = header
}
}
public func update(headerStatic: String?) {
if self.headerStatic != headerStatic {
self.headerStatic = headerStatic
}
}
public func update(note: String?) {
if self.note != note {
self.note = note
}
}
public func update(url: String?) {
if self.url != url {
self.url = url
}
}
public func update(statusesCount: Int) {
if self.statusesCount.intValue != statusesCount {
self.statusesCount = NSNumber(value: statusesCount)
}
}
public func update(followingCount: Int) {
if self.followingCount.intValue != followingCount {
self.followingCount = NSNumber(value: followingCount)
}
}
public func update(followersCount: Int) {
if self.followersCount.intValue != followersCount {
self.followersCount = NSNumber(value: followersCount)
}
}
public func update(isFollowing: Bool, by mastodonUser: MastodonUser) {
if isFollowing {
if !(self.followingBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(MastodonUser.followingBy)).add(mastodonUser)
}
} else {
if (self.followingBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(MastodonUser.followingBy)).remove(mastodonUser)
}
}
}
public func update(isFollowRequested: Bool, by mastodonUser: MastodonUser) {
if isFollowRequested {
if !(self.followRequestedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(MastodonUser.followRequestedBy)).add(mastodonUser)
}
} else {
if (self.followRequestedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(MastodonUser.followRequestedBy)).remove(mastodonUser)
}
}
}
public func update(isMuting: Bool, by mastodonUser: MastodonUser) {
if isMuting {
if !(self.mutingBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(MastodonUser.mutingBy)).add(mastodonUser)
}
} else {
if (self.mutingBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(MastodonUser.mutingBy)).remove(mastodonUser)
}
}
}
public func update(isBlocking: Bool, by mastodonUser: MastodonUser) {
if isBlocking {
if !(self.blockingBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(MastodonUser.blockingBy)).add(mastodonUser)
}
} else {
if (self.blockingBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(MastodonUser.blockingBy)).remove(mastodonUser)
}
}
}
public func update(isEndorsed: Bool, by mastodonUser: MastodonUser) {
if isEndorsed {
if !(self.endorsedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(MastodonUser.endorsedBy)).add(mastodonUser)
}
} else {
if (self.endorsedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(MastodonUser.endorsedBy)).remove(mastodonUser)
}
}
}
public func update(isDomainBlocking: Bool, by mastodonUser: MastodonUser) {
if isDomainBlocking {
if !(self.domainBlockingBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(MastodonUser.domainBlockingBy)).add(mastodonUser)
}
} else {
if (self.domainBlockingBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(MastodonUser.domainBlockingBy)).remove(mastodonUser)
}
}
}
public func didUpdate(at networkDate: Date) {
self.updatedAt = networkDate
@ -100,8 +231,8 @@ extension MastodonUser {
}
public extension MastodonUser {
struct Property {
extension MastodonUser {
public struct Property {
public let identifier: String
public let domain: String
@ -111,6 +242,13 @@ public extension MastodonUser {
public let displayName: String
public let avatar: String
public let avatarStatic: String?
public let header: String
public let headerStatic: String?
public let note: String?
public let url: String?
public let statusesCount: Int
public let followingCount: Int
public let followersCount: Int
public let createdAt: Date
public let networkDate: Date
@ -123,6 +261,13 @@ public extension MastodonUser {
displayName: String,
avatar: String,
avatarStatic: String?,
header: String,
headerStatic: String?,
note: String?,
url: String?,
statusesCount: Int,
followingCount: Int,
followersCount: Int,
createdAt: Date,
networkDate: Date
) {
@ -134,6 +279,13 @@ public extension MastodonUser {
self.displayName = displayName
self.avatar = avatar
self.avatarStatic = avatarStatic
self.header = header
self.headerStatic = headerStatic
self.note = note
self.url = url
self.statusesCount = statusesCount
self.followingCount = followingCount
self.followersCount = followersCount
self.createdAt = createdAt
self.networkDate = networkDate
}

View File

@ -19,7 +19,7 @@ public final class Mention: NSManagedObject {
@NSManaged public private(set) var url: String
// many-to-one relationship
@NSManaged public private(set) var toot: Toot
@NSManaged public private(set) var status: Status
}
public extension Mention {

View File

@ -22,7 +22,7 @@ public final class Poll: NSManagedObject {
@NSManaged public private(set) var updatedAt: Date
// one-to-one relationship
@NSManaged public private(set) var toot: Toot
@NSManaged public private(set) var status: Status
// one-to-many relationship
@NSManaged public private(set) var options: Set<PollOption>

View File

@ -0,0 +1,56 @@
//
// PrivateNote.swift
// CoreDataStack
//
// Created by MainasuK Cirno on 2021-4-1.
//
import CoreData
import Foundation
final public class PrivateNote: NSManagedObject {
@NSManaged public private(set) var note: String?
@NSManaged public private(set) var updatedAt: Date
// many-to-one relationship
@NSManaged public private(set) var to: MastodonUser?
@NSManaged public private(set) var from: MastodonUser
}
extension PrivateNote {
public override func awakeFromInsert() {
super.awakeFromInsert()
setPrimitiveValue(Date(), forKey: #keyPath(PrivateNote.updatedAt))
}
@discardableResult
public static func insert(
into context: NSManagedObjectContext,
property: Property
) -> PrivateNote {
let privateNode: PrivateNote = context.insertObject()
privateNode.note = property.note
return privateNode
}
}
extension PrivateNote {
public struct Property {
public let note: String?
init(note: String) {
self.note = note
}
}
}
extension PrivateNote: Managed {
public static var defaultSortDescriptors: [NSSortDescriptor] {
return [NSSortDescriptor(keyPath: \PrivateNote.updatedAt, ascending: false)]
}
}

View File

@ -8,7 +8,7 @@
import CoreData
import Foundation
public final class Toot: NSManagedObject {
public final class Status: NSManagedObject {
public typealias ID = String
@NSManaged public private(set) var identifier: ID
@ -30,7 +30,7 @@ public final class Toot: NSManagedObject {
@NSManaged public private(set) var repliesCount: NSNumber?
@NSManaged public private(set) var url: String?
@NSManaged public private(set) var inReplyToID: Toot.ID?
@NSManaged public private(set) var inReplyToID: Status.ID?
@NSManaged public private(set) var inReplyToAccountID: MastodonUser.ID?
@NSManaged public private(set) var language: String? // (ISO 639 Part 1 two-letter language code)
@ -38,8 +38,8 @@ public final class Toot: NSManagedObject {
// many-to-one relastionship
@NSManaged public private(set) var author: MastodonUser
@NSManaged public private(set) var reblog: Toot?
@NSManaged public private(set) var replyTo: Toot?
@NSManaged public private(set) var reblog: Status?
@NSManaged public private(set) var replyTo: Status?
// many-to-many relastionship
@NSManaged public private(set) var favouritedBy: Set<MastodonUser>?
@ -52,27 +52,27 @@ public final class Toot: NSManagedObject {
@NSManaged public private(set) var poll: Poll?
// one-to-many relationship
@NSManaged public private(set) var reblogFrom: Set<Toot>?
@NSManaged public private(set) var reblogFrom: Set<Status>?
@NSManaged public private(set) var mentions: Set<Mention>?
@NSManaged public private(set) var emojis: Set<Emoji>?
@NSManaged public private(set) var tags: Set<Tag>?
@NSManaged public private(set) var homeTimelineIndexes: Set<HomeTimelineIndex>?
@NSManaged public private(set) var mediaAttachments: Set<Attachment>?
@NSManaged public private(set) var replyFrom: Set<Toot>?
@NSManaged public private(set) var replyFrom: Set<Status>?
@NSManaged public private(set) var updatedAt: Date
@NSManaged public private(set) var deletedAt: Date?
}
public extension Toot {
public extension Status {
@discardableResult
static func insert(
into context: NSManagedObjectContext,
property: Property,
author: MastodonUser,
reblog: Toot?,
reblog: Status?,
application: Application?,
replyTo: Toot?,
replyTo: Status?,
poll: Poll?,
mentions: [Mention]?,
emojis: [Emoji]?,
@ -83,8 +83,8 @@ public extension Toot {
mutedBy: MastodonUser?,
bookmarkedBy: MastodonUser?,
pinnedBy: MastodonUser?
) -> Toot {
let toot: Toot = context.insertObject()
) -> Status {
let toot: Status = context.insertObject()
toot.identifier = property.identifier
toot.domain = property.domain
@ -117,28 +117,28 @@ public extension Toot {
toot.poll = poll
if let mentions = mentions {
toot.mutableSetValue(forKey: #keyPath(Toot.mentions)).addObjects(from: mentions)
toot.mutableSetValue(forKey: #keyPath(Status.mentions)).addObjects(from: mentions)
}
if let emojis = emojis {
toot.mutableSetValue(forKey: #keyPath(Toot.emojis)).addObjects(from: emojis)
toot.mutableSetValue(forKey: #keyPath(Status.emojis)).addObjects(from: emojis)
}
if let tags = tags {
toot.mutableSetValue(forKey: #keyPath(Toot.tags)).addObjects(from: tags)
toot.mutableSetValue(forKey: #keyPath(Status.tags)).addObjects(from: tags)
}
if let mediaAttachments = mediaAttachments {
toot.mutableSetValue(forKey: #keyPath(Toot.mediaAttachments)).addObjects(from: mediaAttachments)
toot.mutableSetValue(forKey: #keyPath(Status.mediaAttachments)).addObjects(from: mediaAttachments)
}
if let favouritedBy = favouritedBy {
toot.mutableSetValue(forKey: #keyPath(Toot.favouritedBy)).add(favouritedBy)
toot.mutableSetValue(forKey: #keyPath(Status.favouritedBy)).add(favouritedBy)
}
if let rebloggedBy = rebloggedBy {
toot.mutableSetValue(forKey: #keyPath(Toot.rebloggedBy)).add(rebloggedBy)
toot.mutableSetValue(forKey: #keyPath(Status.rebloggedBy)).add(rebloggedBy)
}
if let mutedBy = mutedBy {
toot.mutableSetValue(forKey: #keyPath(Toot.mutedBy)).add(mutedBy)
toot.mutableSetValue(forKey: #keyPath(Status.mutedBy)).add(mutedBy)
}
if let bookmarkedBy = bookmarkedBy {
toot.mutableSetValue(forKey: #keyPath(Toot.bookmarkedBy)).add(bookmarkedBy)
toot.mutableSetValue(forKey: #keyPath(Status.bookmarkedBy)).add(bookmarkedBy)
}
toot.updatedAt = property.networkDate
@ -167,56 +167,56 @@ public extension Toot {
}
}
func update(replyTo: Toot?) {
func update(replyTo: Status?) {
if self.replyTo != replyTo {
self.replyTo = replyTo
}
}
func update(liked: Bool, mastodonUser: MastodonUser) {
func update(liked: Bool, by mastodonUser: MastodonUser) {
if liked {
if !(self.favouritedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(Toot.favouritedBy)).add(mastodonUser)
self.mutableSetValue(forKey: #keyPath(Status.favouritedBy)).add(mastodonUser)
}
} else {
if (self.favouritedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(Toot.favouritedBy)).remove(mastodonUser)
self.mutableSetValue(forKey: #keyPath(Status.favouritedBy)).remove(mastodonUser)
}
}
}
func update(reblogged: Bool, mastodonUser: MastodonUser) {
func update(reblogged: Bool, by mastodonUser: MastodonUser) {
if reblogged {
if !(self.rebloggedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(Toot.rebloggedBy)).add(mastodonUser)
self.mutableSetValue(forKey: #keyPath(Status.rebloggedBy)).add(mastodonUser)
}
} else {
if (self.rebloggedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(Toot.rebloggedBy)).remove(mastodonUser)
self.mutableSetValue(forKey: #keyPath(Status.rebloggedBy)).remove(mastodonUser)
}
}
}
func update(muted: Bool, mastodonUser: MastodonUser) {
func update(muted: Bool, by mastodonUser: MastodonUser) {
if muted {
if !(self.mutedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(Toot.mutedBy)).add(mastodonUser)
self.mutableSetValue(forKey: #keyPath(Status.mutedBy)).add(mastodonUser)
}
} else {
if (self.mutedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(Toot.mutedBy)).remove(mastodonUser)
self.mutableSetValue(forKey: #keyPath(Status.mutedBy)).remove(mastodonUser)
}
}
}
func update(bookmarked: Bool, mastodonUser: MastodonUser) {
func update(bookmarked: Bool, by mastodonUser: MastodonUser) {
if bookmarked {
if !(self.bookmarkedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(Toot.bookmarkedBy)).add(mastodonUser)
self.mutableSetValue(forKey: #keyPath(Status.bookmarkedBy)).add(mastodonUser)
}
} else {
if (self.bookmarkedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(Toot.bookmarkedBy)).remove(mastodonUser)
self.mutableSetValue(forKey: #keyPath(Status.bookmarkedBy)).remove(mastodonUser)
}
}
}
@ -227,7 +227,7 @@ public extension Toot {
}
public extension Toot {
public extension Status {
struct Property {
public let identifier: ID
@ -247,7 +247,7 @@ public extension Toot {
public let repliesCount: NSNumber?
public let url: String?
public let inReplyToID: Toot.ID?
public let inReplyToID: Status.ID?
public let inReplyToAccountID: MastodonUser.ID?
public let language: String? // (ISO 639 Part @1 two-letter language code)
public let text: String?
@ -267,7 +267,7 @@ public extension Toot {
favouritesCount: NSNumber,
repliesCount: NSNumber?,
url: String?,
inReplyToID: Toot.ID?,
inReplyToID: Status.ID?,
inReplyToAccountID: MastodonUser.ID?,
language: String?,
text: String?,
@ -296,20 +296,20 @@ public extension Toot {
}
}
extension Toot: Managed {
extension Status: Managed {
public static var defaultSortDescriptors: [NSSortDescriptor] {
return [NSSortDescriptor(keyPath: \Toot.createdAt, ascending: false)]
return [NSSortDescriptor(keyPath: \Status.createdAt, ascending: false)]
}
}
extension Toot {
extension Status {
static func predicate(domain: String) -> NSPredicate {
return NSPredicate(format: "%K == %@", #keyPath(Toot.domain), domain)
return NSPredicate(format: "%K == %@", #keyPath(Status.domain), domain)
}
static func predicate(id: String) -> NSPredicate {
return NSPredicate(format: "%K == %@", #keyPath(Toot.id), id)
return NSPredicate(format: "%K == %@", #keyPath(Status.id), id)
}
public static func predicate(domain: String, id: String) -> NSPredicate {
@ -320,7 +320,7 @@ extension Toot {
}
static func predicate(ids: [String]) -> NSPredicate {
return NSPredicate(format: "%K IN %@", #keyPath(Toot.id), ids)
return NSPredicate(format: "%K IN %@", #keyPath(Status.id), ids)
}
public static func predicate(domain: String, ids: [String]) -> NSPredicate {
@ -331,10 +331,10 @@ extension Toot {
}
public static func notDeleted() -> NSPredicate {
return NSPredicate(format: "%K == nil", #keyPath(Toot.deletedAt))
return NSPredicate(format: "%K == nil", #keyPath(Status.deletedAt))
}
public static func deleted() -> NSPredicate {
return NSPredicate(format: "%K != nil", #keyPath(Toot.deletedAt))
return NSPredicate(format: "%K != nil", #keyPath(Status.deletedAt))
}
}

View File

@ -17,7 +17,7 @@ public final class Tag: NSManagedObject {
@NSManaged public private(set) var url: String
// many-to-many relationship
@NSManaged public private(set) var toot: Toot
@NSManaged public private(set) var statuses: Set<Status>?
// one-to-many relationship
@NSManaged public private(set) var histories: Set<History>?

View File

@ -32,6 +32,7 @@
"edit": "Edit",
"save": "Save",
"ok": "OK",
"done": "Done",
"confirm": "Confirm",
"continue": "Continue",
"cancel": "Cancel",
@ -65,6 +66,15 @@
"closed": "Closed"
}
},
"firendship": {
"follow": "Follow",
"following": "Following",
"block": "Block",
"blocked": "Blocked",
"mute": "Mute",
"muted": "Muted",
"edit_info": "Edit info"
},
"timeline": {
"loader": {
"load_missing_posts": "Load missing posts",
@ -231,6 +241,18 @@
"private": "Followers only",
"direct": "Only people I mention"
}
},
"profile": {
"dashboard": {
"posts": "posts",
"following": "following",
"followers": "followers"
},
"segmented_control": {
"posts": "Posts",
"replies": "Replies",
"media": "Media"
}
}
}
}

View File

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

View File

@ -43,7 +43,7 @@
2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F20725CD491300561493 /* DisposeBagCollectable.swift */; };
2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */; };
2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */ = {isa = PBXBuildFile; productRef = 2D42FF6025C8177C004A627A /* ActiveLabel */; };
2D42FF6B25C817D2004A627A /* TootContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF6A25C817D2004A627A /* TootContent.swift */; };
2D42FF6B25C817D2004A627A /* MastodonStatusContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF6A25C817D2004A627A /* MastodonStatusContent.swift */; };
2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF7D25C82218004A627A /* ActionToolBarContainer.swift */; };
2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */; };
2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8E25C8228A004A627A /* UIButton.swift */; };
@ -109,7 +109,7 @@
DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */; };
DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */ = {isa = PBXBuildFile; productRef = DB0140BC25C40D7500F9F3CF /* CommonOSLog */; };
DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140CE25C42AEE00F9F3CF /* OSLog.swift */; };
DB084B5725CBC56C00F898ED /* Toot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB084B5625CBC56C00F898ED /* Toot.swift */; };
DB084B5725CBC56C00F898ED /* Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB084B5625CBC56C00F898ED /* Status.swift */; };
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 */; };
@ -125,6 +125,9 @@
DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2B3AE825E38850007045F9 /* UIViewPreview.swift */; };
DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */; };
DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */; };
DB35FC1F2612F1D9006193C9 /* ProfileFriendshipActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC1E2612F1D9006193C9 /* ProfileFriendshipActionButton.swift */; };
DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */; };
DB35FC2F26130172006193C9 /* MastodonField.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC2E26130172006193C9 /* MastodonField.swift */; };
DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DB3D0FF225BAA61700EAA174 /* AlamofireImage */; };
DB3D100D25BAA75E00EAA174 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = DB3D100F25BAA75E00EAA174 /* Localizable.strings */; };
DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DD525BAA00100D1B89D /* AppDelegate.swift */; };
@ -155,6 +158,9 @@
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 */; };
DB482A3F261331E8008AE74C /* UserTimelineViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB482A3E261331E8008AE74C /* UserTimelineViewModel+State.swift */; };
DB482A45261335BA008AE74C /* UserTimelineViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB482A44261335BA008AE74C /* UserTimelineViewController+StatusProvider.swift */; };
DB482A4B261340A7008AE74C /* APIService+UserTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB482A4A261340A7008AE74C /* APIService+UserTimeline.swift */; };
DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A61325FF2C5600B98345 /* EmojiService.swift */; };
DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */; };
DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */; };
@ -173,7 +179,7 @@
DB66729625F9F91600D60309 /* ComposeStatusSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66729525F9F91600D60309 /* ComposeStatusSection.swift */; };
DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */; };
DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */; };
DB68A04A25E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */; };
DB68A04A25E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A04925E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift */; };
DB68A05D25E9055900CFDF14 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = DB68A05C25E9055900CFDF14 /* Settings.bundle */; };
DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A06225E905E000CFDF14 /* UIApplication.swift */; };
DB6B35182601FA3400DC1E11 /* MastodonAttachmentService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */; };
@ -190,7 +196,7 @@
DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */; };
DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */; };
DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */; };
DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift */; };
DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift */; };
DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */; };
DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */; };
DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D44A2609C11900D12C0D /* PollOptionView.swift */; };
@ -205,7 +211,7 @@
DB89BA1B25C1107F008580ED /* Collection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA1825C1107F008580ED /* Collection.swift */; };
DB89BA1C25C1107F008580ED /* NSManagedObjectContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA1925C1107F008580ED /* NSManagedObjectContext.swift */; };
DB89BA1D25C1107F008580ED /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA1A25C1107F008580ED /* URL.swift */; };
DB89BA2725C110B4008580ED /* Toot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA2625C110B4008580ED /* Toot.swift */; };
DB89BA2725C110B4008580ED /* Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA2625C110B4008580ED /* Status.swift */; };
DB89BA3725C1145C008580ED /* CoreData.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA3525C1145C008580ED /* CoreData.xcdatamodeld */; };
DB89BA4325C1165F008580ED /* NetworkUpdatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA4125C1165F008580ED /* NetworkUpdatable.swift */; };
DB89BA4425C1165F008580ED /* Managed.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA4225C1165F008580ED /* Managed.swift */; };
@ -244,9 +250,30 @@
DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */; };
DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */; };
DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */; };
DBB525082611EAC0002F1F29 /* Tabman in Frameworks */ = {isa = PBXBuildFile; productRef = DBB525072611EAC0002F1F29 /* Tabman */; };
DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */; };
DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */; };
DBB525302611EBF3002F1F29 /* ProfilePagingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5252F2611EBF3002F1F29 /* ProfilePagingViewModel.swift */; };
DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525352611ECEB002F1F29 /* UserTimelineViewController.swift */; };
DBB525412611ED54002F1F29 /* ProfileHeaderViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525402611ED54002F1F29 /* ProfileHeaderViewController.swift */; };
DBB525502611ED6D002F1F29 /* ProfileHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5254F2611ED6D002F1F29 /* ProfileHeaderView.swift */; };
DBB525562611EDCA002F1F29 /* UserTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525552611EDCA002F1F29 /* UserTimelineViewModel.swift */; };
DBB5255E2611F07A002F1F29 /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */; };
DBB525642612C988002F1F29 /* MeProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525632612C988002F1F29 /* MeProfileViewModel.swift */; };
DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5256D2612D5A1002F1F29 /* ProfileStatusDashboardView.swift */; };
DBB525852612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */; };
DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */; };
DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */; };
DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */; };
DBCBED1726132DB500B49291 /* UserTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCBED1626132DB500B49291 /* UserTimelineViewModel+Diffable.swift */; };
DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */; };
DBCC3B30261440A50045B23D /* UITabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B2F261440A50045B23D /* UITabBarController.swift */; };
DBCC3B36261440BA0045B23D /* UINavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B35261440BA0045B23D /* UINavigationController.swift */; };
DBCC3B7B261443AD0045B23D /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B7A261443AD0045B23D /* ViewController.swift */; };
DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B88261454BA0045B23D /* CGImage.swift */; };
DBCC3B8F26148F7B0045B23D /* CachedProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B8E26148F7B0045B23D /* CachedProfileViewModel.swift */; };
DBCC3B9526157E6E0045B23D /* APIService+Relationship.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */; };
DBCC3B9B261584A00045B23D /* PrivateNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B9A2615849F0045B23D /* PrivateNote.swift */; };
DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */; };
DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; };
DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; };
@ -343,7 +370,7 @@
2D38F1FD25CD481700561493 /* StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusProvider.swift; sourceTree = "<group>"; };
2D38F20725CD491300561493 /* DisposeBagCollectable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisposeBagCollectable.swift; sourceTree = "<group>"; };
2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITapGestureRecognizer.swift; sourceTree = "<group>"; };
2D42FF6A25C817D2004A627A /* TootContent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TootContent.swift; sourceTree = "<group>"; };
2D42FF6A25C817D2004A627A /* MastodonStatusContent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonStatusContent.swift; sourceTree = "<group>"; };
2D42FF7D25C82218004A627A /* ActionToolBarContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionToolBarContainer.swift; sourceTree = "<group>"; };
2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HitTestExpandedButton.swift; sourceTree = "<group>"; };
2D42FF8E25C8228A004A627A /* UIButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIButton.swift; sourceTree = "<group>"; };
@ -412,7 +439,7 @@
DB0140A725C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift; sourceTree = "<group>"; };
DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewModel.swift; sourceTree = "<group>"; };
DB0140CE25C42AEE00F9F3CF /* OSLog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = "<group>"; };
DB084B5625CBC56C00F898ED /* Toot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Toot.swift; sourceTree = "<group>"; };
DB084B5625CBC56C00F898ED /* Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Status.swift; sourceTree = "<group>"; };
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>"; };
@ -429,6 +456,9 @@
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>"; };
DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollExpiresOptionCollectionViewCell.swift; sourceTree = "<group>"; };
DB35FC1E2612F1D9006193C9 /* ProfileFriendshipActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFriendshipActionButton.swift; sourceTree = "<group>"; };
DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldView.swift; sourceTree = "<group>"; };
DB35FC2E26130172006193C9 /* MastodonField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonField.swift; sourceTree = "<group>"; };
DB3D0FED25BAA42200EAA174 /* MastodonSDK */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MastodonSDK; sourceTree = "<group>"; };
DB3D100E25BAA75E00EAA174 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
DB427DD225BAA00100D1B89D /* Mastodon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Mastodon.app; sourceTree = BUILT_PRODUCTS_DIR; };
@ -465,6 +495,9 @@
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>"; };
DB482A3E261331E8008AE74C /* UserTimelineViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserTimelineViewModel+State.swift"; sourceTree = "<group>"; };
DB482A44261335BA008AE74C /* UserTimelineViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserTimelineViewController+StatusProvider.swift"; sourceTree = "<group>"; };
DB482A4A261340A7008AE74C /* APIService+UserTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+UserTimeline.swift"; sourceTree = "<group>"; };
DB49A61325FF2C5600B98345 /* EmojiService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiService.swift; sourceTree = "<group>"; };
DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel.swift"; sourceTree = "<group>"; };
DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel+LoadState.swift"; sourceTree = "<group>"; };
@ -482,7 +515,7 @@
DB66729525F9F91600D60309 /* ComposeStatusSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusSection.swift; sourceTree = "<group>"; };
DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusItem.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>"; };
DB68A04925E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AdaptiveStatusBarStyleNavigationController.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>"; };
DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAttachmentService.swift; sourceTree = "<group>"; };
@ -499,7 +532,7 @@
DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewController.swift; sourceTree = "<group>"; };
DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewModel.swift; sourceTree = "<group>"; };
DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusContentCollectionViewCell.swift; sourceTree = "<group>"; };
DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeRepliedToTootContentCollectionViewCell.swift; sourceTree = "<group>"; };
DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeRepliedToStatusContentCollectionViewCell.swift; sourceTree = "<group>"; };
DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentContainerView.swift; sourceTree = "<group>"; };
DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollOptionCollectionViewCell.swift; sourceTree = "<group>"; };
DB87D44A2609C11900D12C0D /* PollOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionView.swift; sourceTree = "<group>"; };
@ -516,7 +549,7 @@
DB89BA1825C1107F008580ED /* Collection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Collection.swift; sourceTree = "<group>"; };
DB89BA1925C1107F008580ED /* NSManagedObjectContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSManagedObjectContext.swift; sourceTree = "<group>"; };
DB89BA1A25C1107F008580ED /* URL.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URL.swift; sourceTree = "<group>"; };
DB89BA2625C110B4008580ED /* Toot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Toot.swift; sourceTree = "<group>"; };
DB89BA2625C110B4008580ED /* Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Status.swift; sourceTree = "<group>"; };
DB89BA3625C1145C008580ED /* CoreData.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = CoreData.xcdatamodel; sourceTree = "<group>"; };
DB89BA4125C1165F008580ED /* NetworkUpdatable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkUpdatable.swift; sourceTree = "<group>"; };
DB89BA4225C1165F008580ED /* Managed.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Managed.swift; sourceTree = "<group>"; };
@ -554,9 +587,29 @@
DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedEdgesButton.swift; sourceTree = "<group>"; };
DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = "<group>"; };
DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = "<group>"; };
DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSegmentedViewController.swift; sourceTree = "<group>"; };
DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewController.swift; sourceTree = "<group>"; };
DBB5252F2611EBF3002F1F29 /* ProfilePagingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewModel.swift; sourceTree = "<group>"; };
DBB525352611ECEB002F1F29 /* UserTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserTimelineViewController.swift; sourceTree = "<group>"; };
DBB525402611ED54002F1F29 /* ProfileHeaderViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderViewController.swift; sourceTree = "<group>"; };
DBB5254F2611ED6D002F1F29 /* ProfileHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderView.swift; sourceTree = "<group>"; };
DBB525552611EDCA002F1F29 /* UserTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserTimelineViewModel.swift; sourceTree = "<group>"; };
DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = "<group>"; };
DBB525632612C988002F1F29 /* MeProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeProfileViewModel.swift; sourceTree = "<group>"; };
DBB5256D2612D5A1002F1F29 /* ProfileStatusDashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStatusDashboardView.swift; sourceTree = "<group>"; };
DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStatusDashboardMeterView.swift; sourceTree = "<group>"; };
DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = "<group>"; };
DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentWarningEditorView.swift; sourceTree = "<group>"; };
DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPublishService.swift; sourceTree = "<group>"; };
DBCBED1626132DB500B49291 /* UserTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserTimelineViewModel+Diffable.swift"; sourceTree = "<group>"; };
DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusFetchedResultsController.swift; sourceTree = "<group>"; };
DBCC3B2F261440A50045B23D /* UITabBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITabBarController.swift; sourceTree = "<group>"; };
DBCC3B35261440BA0045B23D /* UINavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UINavigationController.swift; sourceTree = "<group>"; };
DBCC3B7A261443AD0045B23D /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = "<group>"; };
DBCC3B88261454BA0045B23D /* CGImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGImage.swift; sourceTree = "<group>"; };
DBCC3B8E26148F7B0045B23D /* CachedProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedProfileViewModel.swift; sourceTree = "<group>"; };
DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Relationship.swift"; sourceTree = "<group>"; };
DBCC3B9A2615849F0045B23D /* PrivateNote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateNote.swift; sourceTree = "<group>"; };
DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Reblog.swift"; sourceTree = "<group>"; };
DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = "<group>"; };
DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = "<group>"; };
@ -576,6 +629,7 @@
2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */,
DB9A487E2603456B008B817C /* UITextView+Placeholder in Frameworks */,
2D939AC825EE14620076FA61 /* CropViewController in Frameworks */,
DBB525082611EAC0002F1F29 /* Tabman in Frameworks */,
5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */,
DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */,
2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */,
@ -834,6 +888,7 @@
children = (
2D76319D25C151F600929FB9 /* Section */,
2D7631B125C159E700929FB9 /* Item */,
DBCBED2226132E1D00B49291 /* FetchedResultsController */,
);
path = Diffiable;
sourceTree = "<group>";
@ -958,7 +1013,7 @@
isa = PBXGroup;
children = (
DB45FAE225CA7181005A8AC7 /* MastodonUser.swift */,
DB084B5625CBC56C00F898ED /* Toot.swift */,
DB084B5625CBC56C00F898ED /* Status.swift */,
DB9D6C3725E508BE0051B173 /* Attachment.swift */,
);
path = CoreDataStack;
@ -1039,6 +1094,7 @@
children = (
DB427DE325BAA00100D1B89D /* Info.plist */,
DB89BA1025C10FF5008580ED /* Mastodon.entitlements */,
DBCC3B7A261443AD0045B23D /* ViewController.swift */,
2D76319C25C151DE00929FB9 /* Diffiable */,
DB8AF52A25C13561002E6C99 /* State */,
2D61335525C1886800CAE157 /* Service */,
@ -1089,12 +1145,14 @@
DB98339B25C96DE600AD9700 /* APIService+Account.swift */,
2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */,
DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */,
DB482A4A261340A7008AE74C /* APIService+UserTimeline.swift */,
DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */,
DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */,
DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */,
DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */,
DB9A488326034BD7008B817C /* APIService+Status.swift */,
DB9A488F26035963008B817C /* APIService+Media.swift */,
DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */,
);
path = APIService;
sourceTree = "<group>";
@ -1152,7 +1210,7 @@
DB68A04F25E9028800CFDF14 /* NavigationController */ = {
isa = PBXGroup;
children = (
DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */,
DB68A04925E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift */,
);
path = NavigationController;
sourceTree = "<group>";
@ -1191,7 +1249,7 @@
DB789A2125F9F76D0071ACA0 /* CollectionViewCell */ = {
isa = PBXGroup;
children = (
DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift */,
DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift */,
DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */,
DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift */,
DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */,
@ -1249,7 +1307,7 @@
DB89BA2C25C110B7008580ED /* Entity */ = {
isa = PBXGroup;
children = (
DB89BA2625C110B4008580ED /* Toot.swift */,
DB89BA2625C110B4008580ED /* Status.swift */,
DB8AF52425C131D1002E6C99 /* MastodonUser.swift */,
DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */,
2D927F0125C7E4F2004F19B8 /* Mention.swift */,
@ -1261,6 +1319,7 @@
DB9D6C2D25E504AC0051B173 /* Attachment.swift */,
DB4481AC25EE155900BEFB67 /* Poll.swift */,
DB4481B225EE16D000BEFB67 /* PollOption.swift */,
DBCC3B9A2615849F0045B23D /* PrivateNote.swift */,
);
path = Entity;
sourceTree = "<group>";
@ -1335,6 +1394,7 @@
0FAA101B25E10E760017CCDE /* UIFont.swift */,
2D939AB425EDD8A90076FA61 /* String.swift */,
2D206B7F25F5F45E00143C56 /* UIImage.swift */,
DBCC3B88261454BA0045B23D /* CGImage.swift */,
2D206B8525F5FB0900143C56 /* Double.swift */,
2D206B9125F60EA700143C56 /* UIControl.swift */,
5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */,
@ -1344,6 +1404,8 @@
2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */,
2D84350425FF858100EECE90 /* UIScrollView.swift */,
DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */,
DBCC3B2F261440A50045B23D /* UITabBarController.swift */,
DBCC3B35261440BA0045B23D /* UINavigationController.swift */,
);
path = Extension;
sourceTree = "<group>";
@ -1395,7 +1457,13 @@
DB9D6C0825E4F5A60051B173 /* Profile */ = {
isa = PBXGroup;
children = (
DBB525132611EBB1002F1F29 /* Segmented */,
DBB525462611ED57002F1F29 /* Header */,
DBB5253B2611ECF5002F1F29 /* Timeline */,
DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */,
DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */,
DBCC3B8E26148F7B0045B23D /* CachedProfileViewModel.swift */,
DBB525632612C988002F1F29 /* MeProfileViewModel.swift */,
);
path = Profile;
sourceTree = "<group>";
@ -1425,7 +1493,8 @@
DB9E0D6925EDFFE500CFDD76 /* Helper */ = {
isa = PBXGroup;
children = (
2D42FF6A25C817D2004A627A /* TootContent.swift */,
2D42FF6A25C817D2004A627A /* MastodonStatusContent.swift */,
DB35FC2E26130172006193C9 /* MastodonField.swift */,
);
path = Helper;
sourceTree = "<group>";
@ -1447,6 +1516,65 @@
path = View;
sourceTree = "<group>";
};
DBB525132611EBB1002F1F29 /* Segmented */ = {
isa = PBXGroup;
children = (
DBB525262611EBDA002F1F29 /* Paging */,
DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */,
);
path = Segmented;
sourceTree = "<group>";
};
DBB525262611EBDA002F1F29 /* Paging */ = {
isa = PBXGroup;
children = (
DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */,
DBB5252F2611EBF3002F1F29 /* ProfilePagingViewModel.swift */,
);
path = Paging;
sourceTree = "<group>";
};
DBB5253B2611ECF5002F1F29 /* Timeline */ = {
isa = PBXGroup;
children = (
DBB525352611ECEB002F1F29 /* UserTimelineViewController.swift */,
DB482A44261335BA008AE74C /* UserTimelineViewController+StatusProvider.swift */,
DBB525552611EDCA002F1F29 /* UserTimelineViewModel.swift */,
DBCBED1626132DB500B49291 /* UserTimelineViewModel+Diffable.swift */,
DB482A3E261331E8008AE74C /* UserTimelineViewModel+State.swift */,
);
path = Timeline;
sourceTree = "<group>";
};
DBB525462611ED57002F1F29 /* Header */ = {
isa = PBXGroup;
children = (
DBB525732612D5A5002F1F29 /* View */,
DBB525402611ED54002F1F29 /* ProfileHeaderViewController.swift */,
);
path = Header;
sourceTree = "<group>";
};
DBB525732612D5A5002F1F29 /* View */ = {
isa = PBXGroup;
children = (
DBB5254F2611ED6D002F1F29 /* ProfileHeaderView.swift */,
DBB5256D2612D5A1002F1F29 /* ProfileStatusDashboardView.swift */,
DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */,
DB35FC1E2612F1D9006193C9 /* ProfileFriendshipActionButton.swift */,
DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */,
);
path = View;
sourceTree = "<group>";
};
DBCBED2226132E1D00B49291 /* FetchedResultsController */ = {
isa = PBXGroup;
children = (
DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */,
);
path = FetchedResultsController;
sourceTree = "<group>";
};
DBE0821A25CD382900FD6BBD /* Register */ = {
isa = PBXGroup;
children = (
@ -1500,6 +1628,7 @@
2D939AC725EE14620076FA61 /* CropViewController */,
DB9A487D2603456B008B817C /* UITextView+Placeholder */,
DBE64A8A260C49D200E6359A /* TwitterTextEditor */,
DBB525072611EAC0002F1F29 /* Tabman */,
);
productName = Mastodon;
productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */;
@ -1631,6 +1760,7 @@
2D939AC625EE14620076FA61 /* XCRemoteSwiftPackageReference "TOCropViewController" */,
DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */,
DBE64A89260C49D200E6359A /* XCRemoteSwiftPackageReference "TwitterTextEditor" */,
DBB525062611EAC0002F1F29 /* XCRemoteSwiftPackageReference "Tabman" */,
);
productRefGroup = DB427DD325BAA00100D1B89D /* Products */;
projectDirPath = "";
@ -1815,6 +1945,8 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */,
DB35FC2F26130172006193C9 /* MastodonField.swift in Sources */,
DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */,
5DF1057F25F88A4100D6C0D4 /* TouchBlockingView.swift in Sources */,
0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */,
@ -1822,14 +1954,17 @@
2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */,
DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */,
DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */,
DBCC3B9526157E6E0045B23D /* APIService+Relationship.swift in Sources */,
2D7631B325C159F700929FB9 /* Item.swift in Sources */,
5DF1054125F886D400D6C0D4 /* ViedeoPlaybackService.swift in Sources */,
DB6B35182601FA3400DC1E11 /* MastodonAttachmentService.swift in Sources */,
0FB3D2F725E4C24D00AAD544 /* MastodonPickServerViewModel.swift in Sources */,
2D61335E25C1894B00CAE157 /* APIService.swift in Sources */,
DB66729625F9F91600D60309 /* ComposeStatusSection.swift in Sources */,
DB482A3F261331E8008AE74C /* UserTimelineViewModel+State.swift in Sources */,
2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */,
DB447681260B3ED600B66B82 /* CustomEmojiPickerSection.swift in Sources */,
DBB525502611ED6D002F1F29 /* ProfileHeaderView.swift in Sources */,
0FB3D33225E5F50E00AAD544 /* PickServerSearchCell.swift in Sources */,
DB71FD5225F8CCAA00512AE1 /* APIService+Status.swift in Sources */,
2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */,
@ -1843,6 +1978,7 @@
2D364F7225E66D7500204FDC /* MastodonResendEmailViewController.swift in Sources */,
2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */,
DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */,
DBB5255E2611F07A002F1F29 /* ProfileViewModel.swift in Sources */,
2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift in Sources */,
DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */,
DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */,
@ -1855,19 +1991,23 @@
2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */,
2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */,
DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */,
DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */,
2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */,
DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */,
0FAA102725E1126A0017CCDE /* MastodonPickServerViewController.swift in Sources */,
DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */,
DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */,
2D61335825C188A000CAE157 /* APIService+Persist+Status.swift in Sources */,
DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */,
DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */,
DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */,
DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */,
2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Status.swift in Sources */,
DB4481C625EE2ADA00BEFB67 /* PollSection.swift in Sources */,
DBCBED1726132DB500B49291 /* UserTimelineViewModel+Diffable.swift in Sources */,
DB71FD4C25F8C80E00512AE1 /* StatusPrefetchingService.swift in Sources */,
DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */,
DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */,
2D939AB525EDD8A90076FA61 /* String.swift in Sources */,
DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */,
0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */,
@ -1896,12 +2036,15 @@
2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */,
5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */,
2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */,
DBCC3B36261440BA0045B23D /* UINavigationController.swift in Sources */,
DBB525852612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift in Sources */,
2D84350525FF858100EECE90 /* UIScrollView.swift in Sources */,
DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift in Sources */,
2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */,
DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */,
2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */,
0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */,
DBCC3B7B261443AD0045B23D /* ViewController.swift in Sources */,
2D38F1DF25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift in Sources */,
2D206B9225F60EA700143C56 /* UIControl.swift in Sources */,
2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */,
@ -1918,9 +2061,11 @@
2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */,
5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */,
2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */,
DB35FC1F2612F1D9006193C9 /* ProfileFriendshipActionButton.swift in Sources */,
DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */,
DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */,
DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */,
DBB525562611EDCA002F1F29 /* UserTimelineViewModel.swift in Sources */,
2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */,
DB221B16260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift in Sources */,
DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */,
@ -1936,11 +2081,12 @@
2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */,
DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */,
DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */,
DBB525642612C988002F1F29 /* MeProfileViewModel.swift in Sources */,
DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */,
DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */,
0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */,
DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */,
DB68A04A25E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift in Sources */,
DB68A04A25E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift in Sources */,
0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */,
2D364F7825E66D8300204FDC /* MastodonResendEmailViewModel.swift in Sources */,
DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */,
@ -1948,15 +2094,18 @@
DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */,
2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */,
2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */,
DBCC3B8F26148F7B0045B23D /* CachedProfileViewModel.swift in Sources */,
DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */,
DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */,
DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */,
DB482A45261335BA008AE74C /* UserTimelineViewController+StatusProvider.swift in Sources */,
2D206B8625F5FB0900143C56 /* Double.swift in Sources */,
DB9A485C2603010E008B817C /* PHPickerResultLoader.swift in Sources */,
2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */,
DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */,
2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */,
DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */,
DB084B5725CBC56C00F898ED /* Toot.swift in Sources */,
DB084B5725CBC56C00F898ED /* Status.swift in Sources */,
DB447691260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift in Sources */,
DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */,
DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */,
@ -1982,7 +2131,7 @@
2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */,
2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */,
DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */,
2D42FF6B25C817D2004A627A /* TootContent.swift in Sources */,
2D42FF6B25C817D2004A627A /* MastodonStatusContent.swift in Sources */,
2DF75BA725D10E1000694EC8 /* APIService+Favorite.swift in Sources */,
DB9D6C3825E508BE0051B173 /* Attachment.swift in Sources */,
DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */,
@ -1991,7 +2140,9 @@
2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */,
2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */,
2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */,
DB482A4B261340A7008AE74C /* APIService+UserTimeline.swift in Sources */,
DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */,
DBCC3B30261440A50045B23D /* UITabBarController.swift in Sources */,
DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */,
DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */,
DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */,
@ -1999,18 +2150,22 @@
DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */,
DB87D4512609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift in Sources */,
DB9A489026035963008B817C /* APIService+Media.swift in Sources */,
DBB525302611EBF3002F1F29 /* ProfilePagingViewModel.swift in Sources */,
2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */,
DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */,
DBB525412611ED54002F1F29 /* ProfileHeaderViewController.swift in Sources */,
DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */,
0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */,
DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */,
DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */,
2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */,
DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */,
0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */,
5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */,
DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */,
DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */,
DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */,
DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift in Sources */,
DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -2041,11 +2196,12 @@
DB89BA1C25C1107F008580ED /* NSManagedObjectContext.swift in Sources */,
DB9D6C2E25E504AC0051B173 /* Attachment.swift in Sources */,
2D927F0E25C7E9C9004F19B8 /* History.swift in Sources */,
DBCC3B9B261584A00045B23D /* PrivateNote.swift in Sources */,
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 */,
DB89BA2725C110B4008580ED /* Status.swift in Sources */,
2D152A9225C2980C009AA50C /* UIFont.swift in Sources */,
DB4481B325EE16D000BEFB67 /* PollOption.swift in Sources */,
DB89BA4425C1165F008580ED /* Managed.swift in Sources */,
@ -2605,6 +2761,14 @@
minimumVersion = 1.4.1;
};
};
DBB525062611EAC0002F1F29 /* XCRemoteSwiftPackageReference "Tabman" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/uias/Tabman";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.11.0;
};
};
DBE64A89260C49D200E6359A /* XCRemoteSwiftPackageReference "TwitterTextEditor" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/MainasuK/TwitterTextEditor";
@ -2660,6 +2824,11 @@
package = DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */;
productName = "UITextView+Placeholder";
};
DBB525072611EAC0002F1F29 /* Tabman */ = {
isa = XCSwiftPackageProductDependency;
package = DBB525062611EAC0002F1F29 /* XCRemoteSwiftPackageReference "Tabman" */;
productName = Tabman;
};
DBE64A8A260C49D200E6359A /* TwitterTextEditor */ = {
isa = XCSwiftPackageProductDependency;
package = DBE64A89260C49D200E6359A /* XCRemoteSwiftPackageReference "TwitterTextEditor" */;

View File

@ -55,6 +55,15 @@
"version": "6.1.0"
}
},
{
"package": "Pageboy",
"repositoryURL": "https://github.com/uias/Pageboy",
"state": {
"branch": null,
"revision": "34ecb6e7c4e0e07494960ab2f7cc9a02293915a6",
"version": "3.6.2"
}
},
{
"package": "swift-nio",
"repositoryURL": "https://github.com/apple/swift-nio.git",
@ -82,6 +91,15 @@
"version": "5.0.0"
}
},
{
"package": "Tabman",
"repositoryURL": "https://github.com/uias/Tabman",
"state": {
"branch": null,
"revision": "bce2c87659c0ed868e6ef0aa1e05a330e202533f",
"version": "2.11.0"
}
},
{
"package": "ThirdPartyMailer",
"repositoryURL": "https://github.com/vtourraine/ThirdPartyMailer.git",

View File

@ -50,6 +50,9 @@ extension SceneCoordinator {
// compose
case compose(viewModel: ComposeViewModel)
// profile
case profile(viewModel: ProfileViewModel)
// misc
case alertController(alertController: UIAlertController)
@ -119,17 +122,18 @@ extension SceneCoordinator {
presentingViewController.show(viewController, sender: sender)
case .showDetail:
let navigationController = UINavigationController(rootViewController: viewController)
let navigationController = AdaptiveStatusBarStyleNavigationController(rootViewController: viewController)
presentingViewController.showDetailViewController(navigationController, sender: sender)
case .modal(let animated, let completion):
let modalNavigationController: UINavigationController = {
if scene.isOnboarding {
return DarkContentStatusBarStyleNavigationController(rootViewController: viewController)
return AdaptiveStatusBarStyleNavigationController(rootViewController: viewController)
} else {
return UINavigationController(rootViewController: viewController)
}
}()
modalNavigationController.modalPresentationCapturesStatusBarAppearance = true
if let adaptivePresentationControllerDelegate = viewController as? UIAdaptivePresentationControllerDelegate {
modalNavigationController.presentationController?.delegate = adaptivePresentationControllerDelegate
}
@ -146,12 +150,15 @@ extension SceneCoordinator {
sender?.navigationController?.pushViewController(viewController, animated: true)
case .safariPresent(let animated, let completion):
viewController.modalPresentationCapturesStatusBarAppearance = true
presentingViewController.present(viewController, animated: animated, completion: completion)
case .activityViewControllerPresent(let animated, let completion):
viewController.modalPresentationCapturesStatusBarAppearance = true
presentingViewController.present(viewController, animated: animated, completion: completion)
case .alertController(let animated, let completion):
viewController.modalPresentationCapturesStatusBarAppearance = true
presentingViewController.present(viewController, animated: animated, completion: completion)
}
@ -197,6 +204,10 @@ private extension SceneCoordinator {
let _viewController = ComposeViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .profile(let viewModel):
let _viewController = ProfileViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .alertController(let alertController):
if let popoverPresentationController = alertController.popoverPresentationController {
assert(

View File

@ -0,0 +1,86 @@
//
// StatusFetchedResultsController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-30.
//
import os.log
import UIKit
import Combine
import CoreData
import CoreDataStack
import MastodonSDK
final class StatusFetchedResultsController: NSObject {
var disposeBag = Set<AnyCancellable>()
let fetchedResultsController: NSFetchedResultsController<Status>
// input
let domain = CurrentValueSubject<String?, Never>(nil)
let statusIDs = CurrentValueSubject<[Mastodon.Entity.Status.ID], Never>([])
// output
let items = CurrentValueSubject<[NSManagedObjectID], Never>([])
init(managedObjectContext: NSManagedObjectContext, domain: String?, additionalTweetPredicate: NSPredicate) {
self.domain.value = domain ?? ""
self.fetchedResultsController = {
let fetchRequest = Status.sortedFetchRequest
fetchRequest.predicate = Status.predicate(domain: domain ?? "", ids: [])
fetchRequest.returnsObjectsAsFaults = false
fetchRequest.fetchBatchSize = 20
let controller = NSFetchedResultsController(
fetchRequest: fetchRequest,
managedObjectContext: managedObjectContext,
sectionNameKeyPath: nil,
cacheName: nil
)
return controller
}()
super.init()
fetchedResultsController.delegate = self
Publishers.CombineLatest(
self.domain.removeDuplicates().eraseToAnyPublisher(),
self.statusIDs.removeDuplicates().eraseToAnyPublisher()
)
.receive(on: DispatchQueue.main)
.sink { [weak self] domain, ids in
guard let self = self else { return }
self.fetchedResultsController.fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
Status.predicate(domain: domain ?? "", ids: ids),
additionalTweetPredicate
])
do {
try self.fetchedResultsController.performFetch()
} catch {
assertionFailure(error.localizedDescription)
}
}
.store(in: &disposeBag)
}
}
// MARK: - NSFetchedResultsControllerDelegate
extension StatusFetchedResultsController: NSFetchedResultsControllerDelegate {
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
let indexes = statusIDs.value
let objects = fetchedResultsController.fetchedObjects ?? []
let items: [NSManagedObjectID] = objects
.compactMap { object in
indexes.firstIndex(of: object.id).map { index in (index, object) }
}
.sorted { $0.0 < $1.0 }
.map { $0.1.objectID }
self.items.value = items
}
}

View File

@ -16,40 +16,44 @@ enum Item {
case homeTimelineIndex(objectID: NSManagedObjectID, attribute: StatusAttribute)
// normal list
case toot(objectID: NSManagedObjectID, attribute: StatusAttribute)
case status(objectID: NSManagedObjectID, attribute: StatusAttribute)
// loader
case homeMiddleLoader(upperTimelineIndexAnchorObjectID: NSManagedObjectID)
case publicMiddleLoader(tootID: String)
case publicMiddleLoader(statusID: String)
case bottomLoader
}
protocol StatusContentWarningAttribute {
var isStatusTextSensitive: Bool { get set }
var isStatusSensitive: Bool { get set }
var isStatusTextSensitive: Bool? { get set }
var isStatusSensitive: Bool? { get set }
}
extension Item {
class StatusAttribute: Equatable, Hashable, StatusContentWarningAttribute {
var isStatusTextSensitive: Bool
var isStatusSensitive: Bool
class StatusAttribute: StatusContentWarningAttribute {
var isStatusTextSensitive: Bool?
var isStatusSensitive: Bool?
public init(
isStatusTextSensitive: Bool,
isStatusSensitive: Bool
init(
isStatusTextSensitive: Bool? = nil,
isStatusSensitive: Bool? = nil
) {
self.isStatusTextSensitive = isStatusTextSensitive
self.isStatusSensitive = isStatusSensitive
}
static func == (lhs: Item.StatusAttribute, rhs: Item.StatusAttribute) -> Bool {
return lhs.isStatusTextSensitive == rhs.isStatusTextSensitive &&
lhs.isStatusSensitive == rhs.isStatusSensitive
}
func hash(into hasher: inout Hasher) {
hasher.combine(isStatusTextSensitive)
hasher.combine(isStatusSensitive)
// delay attribute init
func setupForStatus(status: Status) {
if isStatusTextSensitive == nil {
isStatusTextSensitive = {
guard let spoilerText = status.spoilerText, !spoilerText.isEmpty else { return false }
return true
}()
}
if isStatusSensitive == nil {
isStatusSensitive = status.sensitive
}
}
}
}
@ -59,7 +63,7 @@ extension Item: Equatable {
switch (lhs, rhs) {
case (.homeTimelineIndex(let objectIDLeft, _), .homeTimelineIndex(let objectIDRight, _)):
return objectIDLeft == objectIDRight
case (.toot(let objectIDLeft, _), .toot(let objectIDRight, _)):
case (.status(let objectIDLeft, _), .status(let objectIDRight, _)):
return objectIDLeft == objectIDRight
case (.bottomLoader, .bottomLoader):
return true
@ -78,7 +82,7 @@ extension Item: Hashable {
switch self {
case .homeTimelineIndex(let objectID, _):
hasher.combine(objectID)
case .toot(let objectID, _):
case .status(let objectID, _):
hasher.combine(objectID)
case .publicMiddleLoader(let upper):
hasher.combine(String(describing: Item.publicMiddleLoader.self))

View File

@ -49,14 +49,14 @@ extension ComposeStatusSection {
] collectionView, indexPath, item -> UICollectionViewCell? in
switch item {
case .replyTo(let repliedToStatusObjectID):
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeRepliedToTootContentCollectionViewCell.self), for: indexPath) as! ComposeRepliedToTootContentCollectionViewCell
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeRepliedToStatusContentCollectionViewCell.self), for: indexPath) as! ComposeRepliedToStatusContentCollectionViewCell
return cell
case .input(let replyToTootObjectID, let attribute):
case .input(let replyToStatusObjectID, let attribute):
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self), for: indexPath) as! ComposeStatusContentCollectionViewCell
cell.textEditorView.text = attribute.composeContent.value ?? ""
managedObjectContext.perform {
guard let replyToTootObjectID = replyToTootObjectID,
let replyTo = managedObjectContext.object(with: replyToTootObjectID) as? Toot else {
guard let replyToStatusObjectID = replyToStatusObjectID,
let replyTo = managedObjectContext.object(with: replyToStatusObjectID) as? Status else {
cell.statusView.headerContainerStackView.isHidden = true
return
}

View File

@ -39,41 +39,41 @@ extension StatusSection {
dependency: dependency,
readableLayoutFrame: tableView.readableContentGuide.layoutFrame,
timestampUpdatePublisher: timestampUpdatePublisher,
toot: timelineIndex.toot,
status: timelineIndex.status,
requestUserID: timelineIndex.userID,
statusItemAttribute: attribute
)
}
cell.delegate = statusTableViewCellDelegate
return cell
case .toot(let objectID, let attribute):
case .status(let objectID, let attribute):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell
let activeMastodonAuthenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value
let requestUserID = activeMastodonAuthenticationBox?.userID ?? ""
// configure cell
managedObjectContext.performAndWait {
let toot = managedObjectContext.object(with: objectID) as! Toot
let status = managedObjectContext.object(with: objectID) as! Status
StatusSection.configure(
cell: cell,
dependency: dependency,
readableLayoutFrame: tableView.readableContentGuide.layoutFrame,
timestampUpdatePublisher: timestampUpdatePublisher,
toot: toot,
status: status,
requestUserID: requestUserID,
statusItemAttribute: attribute
)
}
cell.delegate = statusTableViewCellDelegate
return cell
case .publicMiddleLoader(let upperTimelineTootID):
case .publicMiddleLoader(let upperTimelineStatusID):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self), for: indexPath) as! TimelineMiddleLoaderTableViewCell
cell.delegate = timelineMiddleLoaderTableViewCellDelegate
timelineMiddleLoaderTableViewCellDelegate?.configure(cell: cell, upperTimelineTootID: upperTimelineTootID, timelineIndexobjectID: nil)
timelineMiddleLoaderTableViewCellDelegate?.configure(cell: cell, upperTimelineStatusID: upperTimelineStatusID, timelineIndexobjectID: nil)
return cell
case .homeMiddleLoader(let upperTimelineIndexObjectID):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self), for: indexPath) as! TimelineMiddleLoaderTableViewCell
cell.delegate = timelineMiddleLoaderTableViewCellDelegate
timelineMiddleLoaderTableViewCellDelegate?.configure(cell: cell, upperTimelineTootID: nil, timelineIndexobjectID: upperTimelineIndexObjectID)
timelineMiddleLoaderTableViewCellDelegate?.configure(cell: cell, upperTimelineStatusID: nil, timelineIndexobjectID: upperTimelineIndexObjectID)
return cell
case .bottomLoader:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell
@ -90,47 +90,50 @@ extension StatusSection {
dependency: NeedsDependency,
readableLayoutFrame: CGRect?,
timestampUpdatePublisher: AnyPublisher<Date, Never>,
toot: Toot,
status: Status,
requestUserID: String,
statusItemAttribute: Item.StatusAttribute
) {
// setup attribute
statusItemAttribute.setupForStatus(status: status.reblog ?? status)
// set header
StatusSection.configureHeader(cell: cell, toot: toot)
ManagedObjectObserver.observe(object: toot)
StatusSection.configureHeader(cell: cell, status: status)
ManagedObjectObserver.observe(object: status)
.receive(on: DispatchQueue.main)
.sink { _ in
// do nothing
} receiveValue: { change in
guard case .update(let object) = change.changeType,
let newToot = object as? Toot else { return }
StatusSection.configureHeader(cell: cell, toot: newToot)
let newStatus = object as? Status else { return }
StatusSection.configureHeader(cell: cell, status: newStatus)
}
.store(in: &cell.disposeBag)
// set name username
cell.statusView.nameLabel.text = {
let author = (toot.reblog ?? toot).author
let author = (status.reblog ?? status).author
return author.displayName.isEmpty ? author.username : author.displayName
}()
cell.statusView.usernameLabel.text = "@" + (toot.reblog ?? toot).author.acct
cell.statusView.usernameLabel.text = "@" + (status.reblog ?? status).author.acct
// set avatar
if let reblog = toot.reblog {
if let reblog = status.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()))
cell.statusView.avatarStackedContainerButton.bottomTrailingAvatarStackedImageView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: status.author.avatarImageURL()))
} else {
cell.statusView.avatarButton.isHidden = false
cell.statusView.avatarStackedContainerButton.isHidden = true
cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: toot.author.avatarImageURL()))
cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: status.author.avatarImageURL()))
}
// set text
cell.statusView.activeTextLabel.config(content: (toot.reblog ?? toot).content)
cell.statusView.activeTextLabel.configure(content: (status.reblog ?? status).content)
// set status text content warning
let spoilerText = (toot.reblog ?? toot).spoilerText ?? ""
let isStatusTextSensitive = statusItemAttribute.isStatusTextSensitive
let isStatusTextSensitive = statusItemAttribute.isStatusTextSensitive ?? false
let spoilerText = (status.reblog ?? status).spoilerText ?? ""
cell.statusView.isStatusTextSensitive = isStatusTextSensitive
cell.statusView.updateContentWarningDisplay(isHidden: !isStatusTextSensitive)
cell.statusView.contentWarningTitle.text = {
@ -142,7 +145,7 @@ extension StatusSection {
}()
// prepare media attachments
let mediaAttachments = Array((toot.reblog ?? toot).mediaAttachments ?? []).sorted { $0.index.compare($1.index) == .orderedAscending }
let mediaAttachments = Array((status.reblog ?? status).mediaAttachments ?? []).sorted { $0.index.compare($1.index) == .orderedAscending }
// set image
let mosiacImageViewModel = MosaicImageViewModel(mediaAttachments: mediaAttachments)
@ -184,7 +187,7 @@ extension StatusSection {
}
}
cell.statusView.statusMosaicImageViewContainer.isHidden = mosiacImageViewModel.metas.isEmpty
let isStatusSensitive = statusItemAttribute.isStatusSensitive
let isStatusSensitive = statusItemAttribute.isStatusSensitive ?? false
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
@ -251,7 +254,7 @@ extension StatusSection {
cell.statusView.playerContainerView.playerViewController.player = nil
}
// set poll
let poll = (toot.reblog ?? toot).poll
let poll = (status.reblog ?? status).poll
StatusSection.configurePoll(
cell: cell,
poll: poll,
@ -278,10 +281,10 @@ extension StatusSection {
}
// toolbar
StatusSection.configureActionToolBar(cell: cell, toot: toot, requestUserID: requestUserID)
StatusSection.configureActionToolBar(cell: cell, status: status, requestUserID: requestUserID)
// set date
let createdAt = (toot.reblog ?? toot).createdAt
let createdAt = (status.reblog ?? status).createdAt
cell.statusView.dateLabel.text = createdAt.shortTimeAgoSinceNow
timestampUpdatePublisher
.sink { _ in
@ -290,34 +293,34 @@ extension StatusSection {
.store(in: &cell.disposeBag)
// observe model change
ManagedObjectObserver.observe(object: toot.reblog ?? toot)
ManagedObjectObserver.observe(object: status.reblog ?? status)
.receive(on: DispatchQueue.main)
.sink { _ in
// do nothing
} receiveValue: { change in
guard case .update(let object) = change.changeType,
let toot = object as? Toot else { return }
StatusSection.configureActionToolBar(cell: cell, toot: toot, requestUserID: requestUserID)
let status = object as? Status else { return }
StatusSection.configureActionToolBar(cell: cell, status: status, 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)
os_log("%{public}s[%{public}ld], %{public}s: reblog count label for status %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, status.id, status.reblogsCount.intValue)
os_log("%{public}s[%{public}ld], %{public}s: like count label for status %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, status.id, status.favouritesCount.intValue)
}
.store(in: &cell.disposeBag)
}
static func configureHeader(
cell: StatusTableViewCell,
toot: Toot
status: Status
) {
if toot.reblog != nil {
if status.reblog != nil {
cell.statusView.headerContainerStackView.isHidden = false
cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.boostIconImage)
cell.statusView.headerInfoLabel.text = {
let author = toot.author
let author = status.author
let name = author.displayName.isEmpty ? author.username : author.displayName
return L10n.Common.Controls.Status.userReblogged(name)
}()
} else if let replyTo = toot.replyTo {
} else if let replyTo = status.replyTo {
cell.statusView.headerContainerStackView.isHidden = false
cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage)
cell.statusView.headerInfoLabel.text = {
@ -332,29 +335,29 @@ extension StatusSection {
static func configureActionToolBar(
cell: StatusTableViewCell,
toot: Toot,
status: Status,
requestUserID: String
) {
let toot = toot.reblog ?? toot
let status = status.reblog ?? status
// set reply
let replyCountTitle: String = {
let count = toot.repliesCount?.intValue ?? 0
let count = status.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 isReblogged = status.rebloggedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false
let reblogCountTitle: String = {
let count = toot.reblogsCount.intValue
let count = status.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 isLike = status.favouritedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false
let favoriteCountTitle: String = {
let count = toot.favouritesCount.intValue
let count = status.favouritesCount.intValue
return StatusSection.formattedNumberTitleForActionButton(count)
}()
cell.statusView.actionToolbarContainer.favoriteButton.setTitle(favoriteCountTitle, for: .normal)

View File

@ -14,40 +14,61 @@ extension ActiveLabel {
enum Style {
case `default`
case timelineHeaderView
case profileField
}
convenience init(style: Style) {
self.init()
switch style {
case .default:
font = .preferredFont(forTextStyle: .body)
textColor = Asset.Colors.Label.primary.color
case .timelineHeaderView:
font = .preferredFont(forTextStyle: .footnote)
textColor = .secondaryLabel
}
numberOfLines = 0
lineSpacing = 5
mentionColor = Asset.Colors.Label.highlight.color
hashtagColor = Asset.Colors.Label.highlight.color
URLColor = Asset.Colors.Label.highlight.color
#if DEBUG
text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."
#endif
switch style {
case .default:
font = .preferredFont(forTextStyle: .body)
textColor = Asset.Colors.Label.primary.color
case .profileField:
font = .preferredFont(forTextStyle: .body)
textColor = Asset.Colors.Label.primary.color
numberOfLines = 1
}
}
}
extension ActiveLabel {
func config(content: String) {
/// status content
func configure(content: String) {
activeEntities.removeAll()
if let parseResult = try? TootContent.parse(toot: content) {
if let parseResult = try? MastodonStatusContent.parse(status: content) {
text = parseResult.trimmed
activeEntities = parseResult.activeEntities
} else {
text = ""
}
}
/// account note
func configure(note: String) {
configure(content: note)
}
}
extension ActiveLabel {
/// account field
func configure(field: String) {
activeEntities.removeAll()
if let parseResult = try? MastodonField.parse(field: field) {
text = parseResult.value
activeEntities = parseResult.activeEntities
} else {
text = ""
}
}
}

View File

@ -0,0 +1,154 @@
//
// CGImage.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-31.
//
import CoreImage
extension CGImage {
// Reference
// https://www.itu.int/dms_pubrec/itu-r/rec/bt/R-REC-BT.709-6-201506-I!!PDF-E.pdf
// Luma Y = 0.2126R + 0.7152G + 0.0722B
var brightness: CGFloat? {
let context = CIContext() // default with metal accelerate
let ciImage = CIImage(cgImage: self)
let rec709Image = context.createCGImage(
ciImage,
from: ciImage.extent,
format: .RGBA8,
colorSpace: CGColorSpace(name: CGColorSpace.itur_709) // BT.709 a.k.a Rec.709
)
guard let image = rec709Image,
image.bitsPerPixel == 32,
let data = rec709Image?.dataProvider?.data,
let pointer = CFDataGetBytePtr(data) else { return nil }
let length = CFDataGetLength(data)
guard length > 0 else { return nil}
var luma: CGFloat = 0.0
for i in stride(from: 0, to: length, by: 4) {
let r = pointer[i]
let g = pointer[i + 1]
let b = pointer[i + 2]
let Y = 0.2126 * CGFloat(r) + 0.7152 * CGFloat(g) + 0.0722 * CGFloat(b)
luma += Y
}
luma /= CGFloat(width * height)
return luma
}
}
#if canImport(SwiftUI) && DEBUG
import SwiftUI
import UIKit
class BrightnessView: UIView {
let label = UILabel()
let imageView = UIImageView()
override init(frame: CGRect) {
super.init(frame: frame)
let stackView = UIStackView()
stackView.axis = .horizontal
stackView.translatesAutoresizingMaskIntoConstraints = false
addSubview(stackView)
NSLayoutConstraint.activate([
stackView.topAnchor.constraint(equalTo: topAnchor),
stackView.leadingAnchor.constraint(equalTo: leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: trailingAnchor),
stackView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
stackView.distribution = .fillEqually
stackView.addArrangedSubview(imageView)
stackView.addArrangedSubview(label)
imageView.contentMode = .scaleAspectFill
imageView.layer.masksToBounds = true
label.textAlignment = .center
label.numberOfLines = 0
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setImage(_ image: UIImage) {
imageView.image = image
guard let brightness = image.cgImage?.brightness,
let style = image.domainLumaCoefficientsStyle else {
label.text = "<nil>"
return
}
let styleDescription: String = {
switch style {
case .light: return "Light"
case .dark: return "Dark"
case .unspecified: fallthrough
@unknown default:
return "Unknown"
}
}()
label.text = styleDescription + "\n" + "\(brightness)"
}
}
struct CGImage_Brightness_Previews: PreviewProvider {
static var previews: some View {
Group {
UIViewPreview(width: 375) {
let view = BrightnessView()
view.setImage(.placeholder(color: .black))
return view
}
.previewLayout(.fixed(width: 375, height: 44))
UIViewPreview(width: 375) {
let view = BrightnessView()
view.setImage(.placeholder(color: .gray))
return view
}
.previewLayout(.fixed(width: 375, height: 44))
UIViewPreview(width: 375) {
let view = BrightnessView()
view.setImage(.placeholder(color: .separator))
return view
}
.previewLayout(.fixed(width: 375, height: 44))
UIViewPreview(width: 375) {
let view = BrightnessView()
view.setImage(.placeholder(color: .red))
return view
}
.previewLayout(.fixed(width: 375, height: 44))
UIViewPreview(width: 375) {
let view = BrightnessView()
view.setImage(.placeholder(color: .green))
return view
}
.previewLayout(.fixed(width: 375, height: 44))
UIViewPreview(width: 375) {
let view = BrightnessView()
view.setImage(.placeholder(color: .blue))
return view
}
.previewLayout(.fixed(width: 375, height: 44))
UIViewPreview(width: 375) {
let view = BrightnessView()
view.setImage(.placeholder(color: .secondarySystemGroupedBackground))
return view
}
.previewLayout(.fixed(width: 375, height: 44))
}
}
}
#endif

View File

@ -19,6 +19,13 @@ extension MastodonUser.Property {
displayName: entity.displayName,
avatar: entity.avatar,
avatarStatic: entity.avatarStatic,
header: entity.header,
headerStatic: entity.headerStatic,
note: entity.note,
url: entity.url,
statusesCount: entity.statusesCount,
followingCount: entity.followingCount,
followersCount: entity.followersCount,
createdAt: entity.createdAt,
networkDate: networkDate
)
@ -26,7 +33,25 @@ extension MastodonUser.Property {
}
extension MastodonUser {
var displayNameWithFallback: String {
return !displayName.isEmpty ? displayName : username
}
var acctWithDomain: String {
return username + "@" + domain
}
}
extension MastodonUser {
public func headerImageURL() -> URL? {
return URL(string: header)
}
public func avatarImageURL() -> URL? {
return URL(string: avatar)
}
}

View File

@ -1,5 +1,5 @@
//
// Toot.swift
// Status.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021/2/4.
@ -9,7 +9,7 @@ import Foundation
import CoreDataStack
import MastodonSDK
extension Toot.Property {
extension Status.Property {
init(entity: Mastodon.Entity.Status, domain: String, networkDate: Date) {
self.init(
domain: domain,

View File

@ -39,6 +39,13 @@ extension UIImage {
}
}
extension UIImage {
var domainLumaCoefficientsStyle: UIUserInterfaceStyle? {
guard let brightness = cgImage?.brightness else { return nil }
return brightness > 100 ? .light : .dark // 0 ~ 255
}
}
extension UIImage {
func blur(radius: CGFloat) -> UIImage? {
guard let inputImage = CIImage(image: self) else { return nil }

View File

@ -0,0 +1,17 @@
//
// UINavigationController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-31.
//
import UIKit
// This not works!
// SeeAlso: `AdaptiveStatusBarStyleNavigationController`
extension UINavigationController {
open override var childForStatusBarStyle: UIViewController? {
assertionFailure("Won't enter here")
return visibleViewController
}
}

View File

@ -0,0 +1,14 @@
//
// UITabBarController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-31.
//
import UIKit
extension UITabBarController {
open override var childForStatusBarStyle: UIViewController? {
return selectedViewController
}
}

View File

@ -38,6 +38,7 @@ internal enum Asset {
internal static let disabled = ColorAsset(name: "Colors/Background/Poll/disabled")
internal static let highlight = ColorAsset(name: "Colors/Background/Poll/highlight")
}
internal static let alertYellow = ColorAsset(name: "Colors/Background/alert.yellow")
internal static let dangerBorder = ColorAsset(name: "Colors/Background/danger.border")
internal static let danger = ColorAsset(name: "Colors/Background/danger")
internal static let mediaTypeIndicotor = ColorAsset(name: "Colors/Background/media.type.indicotor")

View File

@ -60,6 +60,8 @@ internal enum L10n {
internal static let `continue` = L10n.tr("Localizable", "Common.Controls.Actions.Continue")
/// Discard
internal static let discard = L10n.tr("Localizable", "Common.Controls.Actions.Discard")
/// Done
internal static let done = L10n.tr("Localizable", "Common.Controls.Actions.Done")
/// Edit
internal static let edit = L10n.tr("Localizable", "Common.Controls.Actions.Edit")
/// OK
@ -85,6 +87,22 @@ internal enum L10n {
/// Try Again
internal static let tryAgain = L10n.tr("Localizable", "Common.Controls.Actions.TryAgain")
}
internal enum Firendship {
/// Block
internal static let block = L10n.tr("Localizable", "Common.Controls.Firendship.Block")
/// Blocked
internal static let blocked = L10n.tr("Localizable", "Common.Controls.Firendship.Blocked")
/// Edit info
internal static let editInfo = L10n.tr("Localizable", "Common.Controls.Firendship.EditInfo")
/// Follow
internal static let follow = L10n.tr("Localizable", "Common.Controls.Firendship.Follow")
/// Following
internal static let following = L10n.tr("Localizable", "Common.Controls.Firendship.Following")
/// Mute
internal static let mute = L10n.tr("Localizable", "Common.Controls.Firendship.Mute")
/// Muted
internal static let muted = L10n.tr("Localizable", "Common.Controls.Firendship.Muted")
}
internal enum Status {
/// Tap to reveal that may be sensitive
internal static let mediaContentWarning = L10n.tr("Localizable", "Common.Controls.Status.MediaContentWarning")
@ -263,6 +281,24 @@ internal enum L10n {
internal static let publishing = L10n.tr("Localizable", "Scene.HomeTimeline.NavigationBarState.Publishing")
}
}
internal enum Profile {
internal enum Dashboard {
/// followers
internal static let followers = L10n.tr("Localizable", "Scene.Profile.Dashboard.Followers")
/// following
internal static let following = L10n.tr("Localizable", "Scene.Profile.Dashboard.Following")
/// posts
internal static let posts = L10n.tr("Localizable", "Scene.Profile.Dashboard.Posts")
}
internal enum SegmentedControl {
/// Media
internal static let media = L10n.tr("Localizable", "Scene.Profile.SegmentedControl.Media")
/// Posts
internal static let posts = L10n.tr("Localizable", "Scene.Profile.SegmentedControl.Posts")
/// Replies
internal static let replies = L10n.tr("Localizable", "Scene.Profile.SegmentedControl.Replies")
}
}
internal enum PublicTimeline {
/// Public
internal static let title = L10n.tr("Localizable", "Scene.PublicTimeline.Title")

View File

@ -0,0 +1,48 @@
//
// MastodonField.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-30.
//
import Foundation
import ActiveLabel
enum MastodonField {
static func parse(field string: String) -> ParseResult {
let mentionMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+))")
let hashtagMatches = string.matches(pattern: "(?:#([^\\s.]+))")
let urlMatches = string.matches(pattern: "(?i)https?://\\S+(?:/|\\b)")
var entities: [ActiveEntity] = []
for match in mentionMatches {
guard let text = string.substring(with: match, at: 0) else { continue }
let entity = ActiveEntity(range: match.range, type: .mention(text, userInfo: nil))
entities.append(entity)
}
for match in hashtagMatches {
guard let text = string.substring(with: match, at: 0) else { continue }
let entity = ActiveEntity(range: match.range, type: .hashtag(text, userInfo: nil))
entities.append(entity)
}
for match in urlMatches {
guard let text = string.substring(with: match, at: 0) else { continue }
let entity = ActiveEntity(range: match.range, type: .url(text, trimmed: text, url: text, userInfo: nil))
entities.append(entity)
}
return ParseResult(value: string, activeEntities: entities)
}
}
extension MastodonField {
struct ParseResult {
let value: String
let activeEntities: [ActiveEntity]
}
}

View File

@ -1,5 +1,5 @@
//
// TootContent.swift
// MastodonStatusContent.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021/2/1.
@ -9,15 +9,15 @@ import Foundation
import Kanna
import ActiveLabel
enum TootContent {
enum MastodonStatusContent {
static func parse(toot: String) throws -> TootContent.ParseResult {
let toot = toot.replacingOccurrences(of: "<br/>", with: "\n")
let rootNode = try Node.parse(document: toot)
static func parse(status: String) throws -> MastodonStatusContent.ParseResult {
let status = status.replacingOccurrences(of: "<br/>", with: "\n")
let rootNode = try Node.parse(document: status)
let text = String(rootNode.text)
var activeEntities: [ActiveEntity] = []
let entities = TootContent.Node.entities(in: rootNode)
let entities = MastodonStatusContent.Node.entities(in: rootNode)
for entity in entities {
let range = NSRange(entity.text.startIndex..<entity.text.endIndex, in: text)
@ -48,22 +48,22 @@ enum TootContent {
var trimmed = text
for activeEntity in activeEntities {
guard case .url = activeEntity.type else { continue }
TootContent.trimEntity(toot: &trimmed, activeEntity: activeEntity, activeEntities: activeEntities)
MastodonStatusContent.trimEntity(status: &trimmed, activeEntity: activeEntity, activeEntities: activeEntities)
}
return ParseResult(
document: toot,
document: status,
original: text,
trimmed: trimmed,
activeEntities: validate(text: trimmed, activeEntities: activeEntities) ? activeEntities : []
)
}
static func trimEntity(toot: inout String, activeEntity: ActiveEntity, activeEntities: [ActiveEntity]) {
static func trimEntity(status: inout String, activeEntity: ActiveEntity, activeEntities: [ActiveEntity]) {
guard case let .url(text, trimmed, _, _) = activeEntity.type else { return }
guard let index = activeEntities.firstIndex(where: { $0.range == activeEntity.range }) else { return }
guard let range = Range(activeEntity.range, in: toot) else { return }
toot.replaceSubrange(range, with: trimmed)
guard let range = Range(activeEntity.range, in: status) else { return }
status.replaceSubrange(range, with: trimmed)
let offset = trimmed.count - text.count
activeEntity.range.length += offset
@ -97,7 +97,7 @@ extension String {
}
}
extension TootContent {
extension MastodonStatusContent {
struct ParseResult {
let document: String
let original: String
@ -106,8 +106,7 @@ extension TootContent {
}
}
extension TootContent {
extension MastodonStatusContent {
class Node {
@ -167,12 +166,12 @@ extension TootContent {
self.children = children
}
static func parse(document: String) throws -> TootContent.Node {
static func parse(document: String) throws -> MastodonStatusContent.Node {
let html = try HTML(html: document, encoding: .utf8)
let body = html.body ?? nil
let text = body?.text ?? ""
let level = 0
let children: [TootContent.Node] = body.flatMap { body in
let children: [MastodonStatusContent.Node] = body.flatMap { body in
return Node.parse(element: body, parentText: text[...], parentLevel: level + 1)
} ?? []
let node = Node(
@ -253,32 +252,32 @@ extension TootContent {
}
extension TootContent.Node {
extension MastodonStatusContent.Node {
enum `Type` {
case url
case mention
case hashtag
}
static func entities(in node: TootContent.Node) -> [TootContent.Node] {
return TootContent.Node.collect(node: node) { node in node.type != nil }
static func entities(in node: MastodonStatusContent.Node) -> [MastodonStatusContent.Node] {
return MastodonStatusContent.Node.collect(node: node) { node in node.type != nil }
}
static func hashtags(in node: TootContent.Node) -> [TootContent.Node] {
return TootContent.Node.collect(node: node) { node in node.type == .hashtag }
static func hashtags(in node: MastodonStatusContent.Node) -> [MastodonStatusContent.Node] {
return MastodonStatusContent.Node.collect(node: node) { node in node.type == .hashtag }
}
static func mentions(in node: TootContent.Node) -> [TootContent.Node] {
return TootContent.Node.collect(node: node) { node in node.type == .mention }
static func mentions(in node: MastodonStatusContent.Node) -> [MastodonStatusContent.Node] {
return MastodonStatusContent.Node.collect(node: node) { node in node.type == .mention }
}
static func urls(in node: TootContent.Node) -> [TootContent.Node] {
return TootContent.Node.collect(node: node) { node in node.type == .url }
static func urls(in node: MastodonStatusContent.Node) -> [MastodonStatusContent.Node] {
return MastodonStatusContent.Node.collect(node: node) { node in node.type == .url }
}
}
extension TootContent.Node: CustomDebugStringConvertible {
extension MastodonStatusContent.Node: CustomDebugStringConvertible {
var debugDescription: String {
let linkInfo: String = {
switch (href, hrefEllipsis) {

View File

@ -73,7 +73,5 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
</dict>
</plist>

View File

@ -13,6 +13,19 @@ import CoreDataStack
import MastodonSDK
import ActiveLabel
// MARK: - StatusViewDelegate
extension StatusTableViewCellDelegate where Self: StatusProvider {
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, headerInfoLabelDidPressed label: UILabel) {
StatusProviderFacade.coordinateToStatusAuthorProfileScene(for: .secondary, provider: self, cell: cell)
}
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, avatarButtonDidPressed button: UIButton) {
StatusProviderFacade.coordinateToStatusAuthorProfileScene(for: .primary, provider: self, cell: cell)
}
}
// MARK: - ActionToolbarContainerDelegate
extension StatusTableViewCellDelegate where Self: StatusProvider {
@ -31,7 +44,7 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
switch item {
case .homeTimelineIndex(_, let attribute):
attribute.isStatusTextSensitive = false
case .toot(_, let attribute):
case .status(_, let attribute):
attribute.isStatusTextSensitive = false
default:
return
@ -66,7 +79,7 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
switch item {
case .homeTimelineIndex(_, let attribute):
attribute.isStatusSensitive = false
case .toot(_, let attribute):
case .status(_, let attribute):
attribute.isStatusSensitive = false
default:
return
@ -89,16 +102,16 @@ 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)
status(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 }
.compactMap { status -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Poll>, Error>? in
guard let status = (status?.reblog ?? status) else { return nil }
guard let poll = status.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
let domain = poll.status.domain
button.isEnabled = false
@ -137,7 +150,7 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
let poll = option.poll
let pollObjectID = option.poll.objectID
let domain = poll.toot.domain
let domain = poll.status.domain
if poll.multiple {
var votedOptions = poll.options.filter { ($0.votedBy ?? Set()).contains(where: { $0.id == activeMastodonAuthenticationBox.userID }) }

View File

@ -11,7 +11,7 @@ import CoreDataStack
extension StatusTableViewCellDelegate where Self: StatusProvider {
func handleTableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
// prefetch reply toot
// prefetch reply status
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return }
let domain = activeMastodonAuthenticationBox.domain
@ -20,8 +20,8 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
switch item {
case .homeTimelineIndex(let objectID, _):
let homeTimelineIndex = managedObjectContext.object(with: objectID) as! HomeTimelineIndex
statusObjectIDs.append(homeTimelineIndex.toot.objectID)
case .toot(let objectID, _):
statusObjectIDs.append(homeTimelineIndex.status.objectID)
case .status(let objectID, _):
statusObjectIDs.append(objectID)
default:
continue
@ -32,15 +32,15 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
backgroundManagedObjectContext.perform { [weak self] in
guard let self = self else { return }
for objectID in statusObjectIDs {
let toot = backgroundManagedObjectContext.object(with: objectID) as! Toot
guard let replyToID = toot.inReplyToID, toot.replyTo == nil else {
let status = backgroundManagedObjectContext.object(with: objectID) as! Status
guard let replyToID = status.inReplyToID, status.replyTo == nil else {
// skip
continue
}
self.context.statusPrefetchingService.prefetchReplyTo(
domain: domain,
statusObjectID: toot.objectID,
statusID: toot.id,
statusObjectID: status.objectID,
statusID: status.id,
replyToStatusID: replyToID,
authorizationBox: activeMastodonAuthenticationBox
)

View File

@ -17,15 +17,15 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
// }
func handleTableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
// update poll when toot appear
// update poll when status 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
status(for: cell, indexPath: indexPath)
.compactMap { [weak self] status -> 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 }
guard let status = (status?.reblog ?? status) else { return nil }
guard let poll = status.poll else { return nil }
pollID = poll.id
// not expired AND last update > 60s
@ -46,7 +46,7 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
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,
domain: status.domain,
pollID: poll.id,
pollObjectID: poll.objectID,
mastodonAuthenticationBox: authenticationBox
@ -68,11 +68,11 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
})
.store(in: &disposeBag)
toot(for: cell, indexPath: indexPath)
.sink { [weak self] toot in
status(for: cell, indexPath: indexPath)
.sink { [weak self] status in
guard let self = self else { return }
let toot = toot?.reblog ?? toot
guard let media = (toot?.mediaAttachments ?? Set()).first else { return }
let status = status?.reblog ?? status
guard let media = (status?.mediaAttachments ?? Set()).first else { return }
guard let videoPlayerViewModel = self.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: media) else { return }
DispatchQueue.main.async {
@ -85,17 +85,17 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
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
status(for: cell, indexPath: indexPath)
.sink { [weak self] status in
guard let self = self else { return }
guard let media = (toot?.mediaAttachments ?? Set()).first else { return }
guard let media = (status?.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) {
if let currentAudioAttachment = self.context.audioPlaybackService.attachment, let _ = status?.mediaAttachments?.contains(currentAudioAttachment) {
self.context.audioPlaybackService.pause()
}
}

View File

@ -12,9 +12,9 @@ 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>
func status() -> Future<Status?, Never>
func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future<Status?, Never>
func status(for cell: UICollectionViewCell) -> Future<Status?, Never>
// sync
var managedObjectContext: NSManagedObjectContext { get }

View File

@ -13,8 +13,53 @@ import CoreDataStack
import MastodonSDK
import ActiveLabel
enum StatusProviderFacade {
enum StatusProviderFacade { }
extension StatusProviderFacade {
static func coordinateToStatusAuthorProfileScene(for target: Target, provider: StatusProvider) {
_coordinateToStatusAuthorProfileScene(
for: target,
provider: provider,
status: provider.status()
)
}
static func coordinateToStatusAuthorProfileScene(for target: Target, provider: StatusProvider, cell: UITableViewCell) {
_coordinateToStatusAuthorProfileScene(
for: target,
provider: provider,
status: provider.status(for: cell, indexPath: nil)
)
}
private static func _coordinateToStatusAuthorProfileScene(for target: Target, provider: StatusProvider, status: Future<Status?, Never>) {
status
.sink { [weak provider] status in
guard let provider = provider else { return }
let _status: Status? = {
switch target {
case .primary: return status?.reblog ?? status // original status
case .secondary: return status?.replyTo ?? status // reblog or reply to status
}
}()
guard let status = _status else { return }
let mastodonUser = status.author
let profileViewModel = CachedProfileViewModel(context: provider.context, mastodonUser: mastodonUser)
DispatchQueue.main.async {
if provider.navigationController == nil {
let from = provider.presentingViewController ?? provider
provider.dismiss(animated: true) {
provider.coordinator.present(scene: .profile(viewModel: profileViewModel), from: from, transition: .show)
}
} else {
provider.coordinator.present(scene: .profile(viewModel: profileViewModel), from: provider, transition: .show)
}
}
}
.store(in: &provider.disposeBag)
}
}
extension StatusProviderFacade {
@ -22,18 +67,18 @@ extension StatusProviderFacade {
static func responseToStatusLikeAction(provider: StatusProvider) {
_responseToStatusLikeAction(
provider: provider,
toot: provider.toot()
status: provider.status()
)
}
static func responseToStatusLikeAction(provider: StatusProvider, cell: UITableViewCell) {
_responseToStatusLikeAction(
provider: provider,
toot: provider.toot(for: cell, indexPath: nil)
status: provider.status(for: cell, indexPath: nil)
)
}
private static func _responseToStatusLikeAction(provider: StatusProvider, toot: Future<Toot?, Never>) {
private static func _responseToStatusLikeAction(provider: StatusProvider, status: Future<Status?, Never>) {
// prepare authentication
guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else {
assertionFailure()
@ -55,22 +100,22 @@ extension StatusProviderFacade {
let generator = UIImpactFeedbackGenerator(style: .light)
let responseFeedbackGenerator = UIImpactFeedbackGenerator(style: .medium)
toot
.compactMap { toot -> (NSManagedObjectID, Mastodon.API.Favorites.FavoriteKind)? in
guard let toot = toot?.reblog ?? toot else { return nil }
status
.compactMap { status -> (NSManagedObjectID, Mastodon.API.Favorites.FavoriteKind)? in
guard let status = status?.reblog ?? status else { return nil }
let favoriteKind: Mastodon.API.Favorites.FavoriteKind = {
let isLiked = toot.favouritedBy.flatMap { $0.contains(where: { $0.id == mastodonUserID }) } ?? false
let isLiked = status.favouritedBy.flatMap { $0.contains(where: { $0.id == mastodonUserID }) } ?? false
return isLiked ? .destroy : .create
}()
return (toot.objectID, favoriteKind)
return (status.objectID, favoriteKind)
}
.map { tootObjectID, favoriteKind -> AnyPublisher<(Toot.ID, Mastodon.API.Favorites.FavoriteKind), Error> in
.map { statusObjectID, favoriteKind -> AnyPublisher<(Status.ID, Mastodon.API.Favorites.FavoriteKind), Error> in
return context.apiService.like(
tootObjectID: tootObjectID,
statusObjectID: statusObjectID,
mastodonUserObjectID: mastodonUserObjectID,
favoriteKind: favoriteKind
)
.map { tootID in (tootID, favoriteKind) }
.map { statusID in (statusID, favoriteKind) }
.eraseToAnyPublisher()
}
.setFailureType(to: Error.self)
@ -82,7 +127,7 @@ extension StatusProviderFacade {
responseFeedbackGenerator.prepare()
} receiveOutput: { _, favoriteKind in
generator.impactOccurred()
os_log("%{public}s[%{public}ld], %{public}s: [Like] update local toot like status to: %s", ((#file as NSString).lastPathComponent), #line, #function, favoriteKind == .create ? "like" : "unlike")
os_log("%{public}s[%{public}ld], %{public}s: [Like] update local status like status to: %s", ((#file as NSString).lastPathComponent), #line, #function, favoriteKind == .create ? "like" : "unlike")
} receiveCompletion: { completion in
switch completion {
case .failure:
@ -92,9 +137,9 @@ extension StatusProviderFacade {
break
}
}
.map { tootID, favoriteKind in
.map { statusID, favoriteKind in
return context.apiService.like(
statusID: tootID,
statusID: statusID,
favoriteKind: favoriteKind,
mastodonAuthenticationBox: activeMastodonAuthenticationBox
)
@ -126,18 +171,18 @@ extension StatusProviderFacade {
static func responseToStatusReblogAction(provider: StatusProvider) {
_responseToStatusReblogAction(
provider: provider,
toot: provider.toot()
status: provider.status()
)
}
static func responseToStatusReblogAction(provider: StatusProvider, cell: UITableViewCell) {
_responseToStatusReblogAction(
provider: provider,
toot: provider.toot(for: cell, indexPath: nil)
status: provider.status(for: cell, indexPath: nil)
)
}
private static func _responseToStatusReblogAction(provider: StatusProvider, toot: Future<Toot?, Never>) {
private static func _responseToStatusReblogAction(provider: StatusProvider, status: Future<Status?, Never>) {
// prepare authentication
guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else {
assertionFailure()
@ -159,22 +204,22 @@ extension StatusProviderFacade {
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 }
status
.compactMap { status -> (NSManagedObjectID, Mastodon.API.Reblog.ReblogKind)? in
guard let status = status?.reblog ?? status else { return nil }
let reblogKind: Mastodon.API.Reblog.ReblogKind = {
let isReblogged = toot.rebloggedBy.flatMap { $0.contains(where: { $0.id == mastodonUserID }) } ?? false
let isReblogged = status.rebloggedBy.flatMap { $0.contains(where: { $0.id == mastodonUserID }) } ?? false
return isReblogged ? .undoReblog : .reblog(query: .init(visibility: nil))
}()
return (toot.objectID, reblogKind)
return (status.objectID, reblogKind)
}
.map { tootObjectID, reblogKind -> AnyPublisher<(Toot.ID, Mastodon.API.Reblog.ReblogKind), Error> in
.map { statusObjectID, reblogKind -> AnyPublisher<(Status.ID, Mastodon.API.Reblog.ReblogKind), Error> in
return context.apiService.reblog(
tootObjectID: tootObjectID,
statusObjectID: statusObjectID,
mastodonUserObjectID: mastodonUserObjectID,
reblogKind: reblogKind
)
.map { tootID in (tootID, reblogKind) }
.map { statusID in (statusID, reblogKind) }
.eraseToAnyPublisher()
}
.setFailureType(to: Error.self)
@ -188,9 +233,9 @@ extension StatusProviderFacade {
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")
os_log("%{public}s[%{public}ld], %{public}s: [Reblog] update local status 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")
os_log("%{public}s[%{public}ld], %{public}s: [Reblog] update local status reblog status to: %s", ((#file as NSString).lastPathComponent), #line, #function, "unreblog")
}
} receiveCompletion: { completion in
switch completion {
@ -201,9 +246,9 @@ extension StatusProviderFacade {
break
}
}
.map { tootID, reblogKind in
.map { statusID, reblogKind in
return context.apiService.reblog(
statusID: tootID,
statusID: statusID,
reblogKind: reblogKind,
mastodonAuthenticationBox: activeMastodonAuthenticationBox
)
@ -231,8 +276,8 @@ extension StatusProviderFacade {
extension StatusProviderFacade {
enum Target {
case toot
case reblog
case primary // original
case secondary // attachment reblog or reply
}
}

View File

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

View File

@ -1,2 +1,2 @@
"NSCameraUsageDescription" = "Used to take photo for toot";
"NSCameraUsageDescription" = "Used to take photo for post status";
"NSPhotoLibraryAddUsageDescription" = "Used to save photo into the Photo Library";

View File

@ -15,6 +15,7 @@ Please check your internet connection.";
"Common.Controls.Actions.Confirm" = "Confirm";
"Common.Controls.Actions.Continue" = "Continue";
"Common.Controls.Actions.Discard" = "Discard";
"Common.Controls.Actions.Done" = "Done";
"Common.Controls.Actions.Edit" = "Edit";
"Common.Controls.Actions.Ok" = "OK";
"Common.Controls.Actions.OpenInSafari" = "Open in Safari";
@ -27,6 +28,13 @@ Please check your internet connection.";
"Common.Controls.Actions.SignUp" = "Sign Up";
"Common.Controls.Actions.TakePhoto" = "Take photo";
"Common.Controls.Actions.TryAgain" = "Try Again";
"Common.Controls.Firendship.Block" = "Block";
"Common.Controls.Firendship.Blocked" = "Blocked";
"Common.Controls.Firendship.EditInfo" = "Edit info";
"Common.Controls.Firendship.Follow" = "Follow";
"Common.Controls.Firendship.Following" = "Following";
"Common.Controls.Firendship.Mute" = "Mute";
"Common.Controls.Firendship.Muted" = "Muted";
"Common.Controls.Status.MediaContentWarning" = "Tap to reveal that may be sensitive";
"Common.Controls.Status.Poll.Closed" = "Closed";
"Common.Controls.Status.Poll.TimeLeft" = "%@ left";
@ -85,6 +93,12 @@ tap the link to confirm your account.";
"Scene.HomeTimeline.NavigationBarState.Published" = "Published!";
"Scene.HomeTimeline.NavigationBarState.Publishing" = "Publishing post...";
"Scene.HomeTimeline.Title" = "Home";
"Scene.Profile.Dashboard.Followers" = "followers";
"Scene.Profile.Dashboard.Following" = "following";
"Scene.Profile.Dashboard.Posts" = "posts";
"Scene.Profile.SegmentedControl.Media" = "Media";
"Scene.Profile.SegmentedControl.Posts" = "Posts";
"Scene.Profile.SegmentedControl.Replies" = "Replies";
"Scene.PublicTimeline.Title" = "Public";
"Scene.Register.Error.Item.Agreement" = "Agreement";
"Scene.Register.Error.Item.Email" = "Email";

View File

@ -1,5 +1,5 @@
//
// ComposeRepliedToTootContentCollectionViewCell.swift
// ComposeRepliedToStatusContentCollectionViewCell.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-11.
@ -7,7 +7,7 @@
import UIKit
final class ComposeRepliedToTootContentCollectionViewCell: UICollectionViewCell {
final class ComposeRepliedToStatusContentCollectionViewCell: UICollectionViewCell {
override init(frame: CGRect) {
super.init(frame: frame)
@ -21,7 +21,7 @@ final class ComposeRepliedToTootContentCollectionViewCell: UICollectionViewCell
}
extension ComposeRepliedToTootContentCollectionViewCell {
extension ComposeRepliedToStatusContentCollectionViewCell {
private func _init() {

View File

@ -43,7 +43,7 @@ final class ComposeViewController: UIViewController, NeedsDependency {
let collectionView: UICollectionView = {
let collectionViewLayout = ComposeViewController.createLayout()
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout)
collectionView.register(ComposeRepliedToTootContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeRepliedToTootContentCollectionViewCell.self))
collectionView.register(ComposeRepliedToStatusContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeRepliedToStatusContentCollectionViewCell.self))
collectionView.register(ComposeStatusContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self))
collectionView.register(ComposeStatusAttachmentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self))
collectionView.register(ComposeStatusPollOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollOptionCollectionViewCell.self))

View File

@ -65,7 +65,7 @@ extension HomeTimelineViewController {
guard let self = self else { return }
self.moveToFirstVideoStatus(action)
}),
UIAction(title: "First GIF Toot", image: nil, attributes: [], handler: { [weak self] action in
UIAction(title: "First GIF status", image: nil, attributes: [], handler: { [weak self] action in
guard let self = self else { return }
self.moveToFirstGIFStatus(action)
}),
@ -112,7 +112,7 @@ extension HomeTimelineViewController {
switch item {
case .homeTimelineIndex(let objectID, _):
let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex
return homeTimelineIndex.toot.reblog != nil
return homeTimelineIndex.status.reblog != nil
default:
return false
}
@ -132,7 +132,7 @@ extension HomeTimelineViewController {
switch item {
case .homeTimelineIndex(let objectID, _):
let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex
let post = homeTimelineIndex.toot.reblog ?? homeTimelineIndex.toot
let post = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status
return post.poll != nil
default:
return false
@ -153,7 +153,7 @@ extension HomeTimelineViewController {
switch item {
case .homeTimelineIndex(let objectID, _):
let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex
guard homeTimelineIndex.toot.inReplyToID != nil else {
guard homeTimelineIndex.status.inReplyToID != nil else {
return false
}
return true
@ -176,8 +176,8 @@ extension HomeTimelineViewController {
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
let status = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status
return status.mediaAttachments?.contains(where: { $0.type == .audio }) ?? false
default:
return false
}
@ -186,7 +186,7 @@ extension HomeTimelineViewController {
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")
print("Not found audio status")
}
}
@ -197,8 +197,8 @@ extension HomeTimelineViewController {
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 == .video }) ?? false
let status = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status
return status.mediaAttachments?.contains(where: { $0.type == .video }) ?? false
default:
return false
}
@ -218,8 +218,8 @@ extension HomeTimelineViewController {
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 == .gifv }) ?? false
let status = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status
return status.mediaAttachments?.contains(where: { $0.type == .gifv }) ?? false
default:
return false
}
@ -242,12 +242,12 @@ extension HomeTimelineViewController {
default: return nil
}
}
var droppingTootObjectIDs: [NSManagedObjectID] = []
var droppingStatusObjectIDs: [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)
droppingStatusObjectIDs.append(homeTimelineIndex.status.objectID)
self.context.apiService.backgroundManagedObjectContext.delete(homeTimelineIndex)
}
}
@ -257,8 +257,8 @@ extension HomeTimelineViewController {
case .success:
self.context.apiService.backgroundManagedObjectContext.performChanges { [weak self] in
guard let self = self else { return }
for objectID in droppingTootObjectIDs {
guard let post = try? self.context.apiService.backgroundManagedObjectContext.existingObject(with: objectID) as? Toot else { continue }
for objectID in droppingStatusObjectIDs {
guard let post = try? self.context.apiService.backgroundManagedObjectContext.existingObject(with: objectID) as? Status else { continue }
self.context.apiService.backgroundManagedObjectContext.delete(post)
}
}

View File

@ -14,11 +14,11 @@ import CoreDataStack
// MARK: - StatusProvider
extension HomeTimelineViewController: StatusProvider {
func toot() -> Future<Toot?, Never> {
func status() -> Future<Status?, Never> {
return Future { promise in promise(.success(nil)) }
}
func toot(for cell: UITableViewCell, indexPath: IndexPath?) -> Future<Toot?, Never> {
func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future<Status?, Never> {
return Future { promise in
guard let diffableDataSource = self.viewModel.diffableDataSource else {
assertionFailure()
@ -36,7 +36,7 @@ extension HomeTimelineViewController: StatusProvider {
let managedObjectContext = self.viewModel.fetchedResultsController.managedObjectContext
managedObjectContext.perform {
let timelineIndex = managedObjectContext.object(with: objectID) as? HomeTimelineIndex
promise(.success(timelineIndex?.toot))
promise(.success(timelineIndex?.status))
}
default:
promise(.success(nil))
@ -44,7 +44,7 @@ extension HomeTimelineViewController: StatusProvider {
}
}
func toot(for cell: UICollectionViewCell) -> Future<Toot?, Never> {
func status(for cell: UICollectionViewCell) -> Future<Status?, Never> {
return Future { promise in promise(.success(nil)) }
}

View File

@ -175,6 +175,13 @@ extension HomeTimelineViewController {
}
.store(in: &disposeBag)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// needs trigger manually after onboarding dismiss
setNeedsStatusBarAppearanceUpdate()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
@ -313,7 +320,7 @@ extension HomeTimelineViewController: ContentOffsetAdjustableTimelineViewControl
// MARK: - TimelineMiddleLoaderTableViewCellDelegate
extension HomeTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate {
func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineTootID: String?, timelineIndexobjectID: NSManagedObjectID?) {
func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineStatusID: String?, timelineIndexobjectID: NSManagedObjectID?) {
guard let upperTimelineIndexObjectID = timelineIndexobjectID else {
return
}

View File

@ -31,6 +31,10 @@ extension HomeTimelineViewModel {
statusTableViewCellDelegate: statusTableViewCellDelegate,
timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate
)
// var snapshot = NSDiffableDataSourceSnapshot<StatusSection, Item>()
// snapshot.appendSections([.main])
// diffableDataSource?.apply(snapshot)
}
}
@ -83,12 +87,7 @@ extension HomeTimelineViewModel: NSFetchedResultsControllerDelegate {
var newTimelineItems: [Item] = []
for (i, timelineIndex) in timelineIndexes.enumerated() {
let toot = timelineIndex.toot.reblog ?? timelineIndex.toot
let isStatusTextSensitive: Bool = {
guard let spoilerText = toot.spoilerText, !spoilerText.isEmpty else { return false }
return true
}()
let attribute = oldSnapshotAttributeDict[timelineIndex.objectID] ?? Item.StatusAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: toot.sensitive)
let attribute = oldSnapshotAttributeDict[timelineIndex.objectID] ?? Item.StatusAttribute()
// append new item into snapshot
newTimelineItems.append(.homeTimelineIndex(objectID: timelineIndex.objectID, attribute: attribute))

View File

@ -55,7 +55,7 @@ extension HomeTimelineViewModel.LoadLatestState {
managedObjectContext.perform {
let start = CACurrentMediaTime()
let latestTootIDs: [Toot.ID]
let latestStatusIDs: [Status.ID]
let request = HomeTimelineIndex.sortedFetchRequest
request.returnsObjectsAsFaults = false
request.predicate = predicate
@ -64,10 +64,10 @@ extension HomeTimelineViewModel.LoadLatestState {
let timelineIndexes = try managedObjectContext.fetch(request)
let endFetch = CACurrentMediaTime()
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: collect timelineIndexes cost: %.2fs", ((#file as NSString).lastPathComponent), #line, #function, endFetch - start)
latestTootIDs = timelineIndexes
.prefix(APIService.onceRequestTootMaxCount) // avoid performance issue
latestStatusIDs = timelineIndexes
.prefix(APIService.onceRequestStatusMaxCount) // avoid performance issue
.compactMap { timelineIndex in
timelineIndex.value(forKeyPath: #keyPath(HomeTimelineIndex.toot.id)) as? Toot.ID
timelineIndex.value(forKeyPath: #keyPath(HomeTimelineIndex.status.id)) as? Status.ID
}
} catch {
stateMachine.enter(Fail.self)
@ -75,7 +75,7 @@ extension HomeTimelineViewModel.LoadLatestState {
}
let end = CACurrentMediaTime()
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: collect toots id cost: %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - start)
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: collect statuses id cost: %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - start)
// TODO: only set large count when using Wi-Fi
viewModel.context.apiService.homeTimeline(domain: activeMastodonAuthenticationBox.domain, authorizationBox: activeMastodonAuthenticationBox)
@ -86,7 +86,7 @@ extension HomeTimelineViewModel.LoadLatestState {
case .failure(let error):
// TODO: handle error
viewModel.isFetchingLatestTimeline.value = false
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
case .finished:
// handle isFetchingLatestTimeline in fetch controller delegate
break
@ -95,15 +95,15 @@ extension HomeTimelineViewModel.LoadLatestState {
stateMachine.enter(Idle.self)
} receiveValue: { response in
// stop refresher if no new toots
let toots = response.value
let newToots = toots.filter { !latestTootIDs.contains($0.id) }
os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld new toots", ((#file as NSString).lastPathComponent), #line, #function, newToots.count)
// stop refresher if no new statuses
let statuses = response.value
let newStatuses = statuses.filter { !latestStatusIDs.contains($0.id) }
os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld new statuses", ((#file as NSString).lastPathComponent), #line, #function, newStatuses.count)
if newToots.isEmpty {
if newStatuses.isEmpty {
viewModel.isFetchingLatestTimeline.value = false
} else {
if !latestTootIDs.isEmpty {
if !latestStatusIDs.isEmpty {
viewModel.homeTimelineNavigationBarTitleViewModel.newPostsIncoming()
}
}

View File

@ -58,12 +58,12 @@ extension HomeTimelineViewModel.LoadMiddleState {
stateMachine.enter(Fail.self)
return
}
let tootIDs = (viewModel.fetchedResultsController.fetchedObjects ?? []).compactMap { timelineIndex in
timelineIndex.toot.id
let statusIDs = (viewModel.fetchedResultsController.fetchedObjects ?? []).compactMap { timelineIndex in
timelineIndex.status.id
}
// TODO: only set large count when using Wi-Fi
let maxID = timelineIndex.toot.id
let maxID = timelineIndex.status.id
viewModel.context.apiService.homeTimeline(domain: activeMastodonAuthenticationBox.domain,maxID: maxID, authorizationBox: activeMastodonAuthenticationBox)
.delay(for: .seconds(1), scheduler: DispatchQueue.main)
.receive(on: DispatchQueue.main)
@ -72,16 +72,16 @@ extension HomeTimelineViewModel.LoadMiddleState {
switch completion {
case .failure(let error):
// TODO: handle error
os_log("%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
os_log("%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
stateMachine.enter(Fail.self)
case .finished:
break
}
} receiveValue: { response in
let toots = response.value
let newToots = toots.filter { !tootIDs.contains($0.id) }
os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld toots, %{public}%ld new toots", ((#file as NSString).lastPathComponent), #line, #function, toots.count, newToots.count)
if newToots.isEmpty {
let statuses = response.value
let newStatuses = statuses.filter { !statusIDs.contains($0.id) }
os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld statuses, %{public}%ld new statuses", ((#file as NSString).lastPathComponent), #line, #function, statuses.count, newStatuses.count)
if newStatuses.isEmpty {
stateMachine.enter(Fail.self)
} else {
stateMachine.enter(Success.self)

View File

@ -53,7 +53,7 @@ extension HomeTimelineViewModel.LoadOldestState {
}
// TODO: only set large count when using Wi-Fi
let maxID = last.toot.id
let maxID = last.status.id
viewModel.context.apiService.homeTimeline(domain: activeMastodonAuthenticationBox.domain, maxID: maxID, authorizationBox: activeMastodonAuthenticationBox)
.delay(for: .seconds(1), scheduler: DispatchQueue.main)
.receive(on: DispatchQueue.main)
@ -61,15 +61,15 @@ extension HomeTimelineViewModel.LoadOldestState {
viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(completion)
switch completion {
case .failure(let error):
os_log("%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
os_log("%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
case .finished:
// handle isFetchingLatestTimeline in fetch controller delegate
break
}
} receiveValue: { response in
let toots = response.value
// enter no more state when no new toots
if toots.isEmpty || (toots.count == 1 && toots[0].id == maxID) {
let statuses = response.value
// enter no more state when no new statuses
if statuses.isEmpty || (statuses.count == 1 && statuses[0].id == maxID) {
stateMachine.enter(NoMore.self)
} else {
stateMachine.enter(Idle.self)

View File

@ -74,7 +74,7 @@ final class HomeTimelineViewModel: NSObject {
let fetchRequest = HomeTimelineIndex.sortedFetchRequest
fetchRequest.fetchBatchSize = 20
fetchRequest.returnsObjectsAsFaults = false
fetchRequest.relationshipKeyPathsForPrefetching = [#keyPath(HomeTimelineIndex.toot)]
fetchRequest.relationshipKeyPathsForPrefetching = [#keyPath(HomeTimelineIndex.status)]
let controller = NSFetchedResultsController(
fetchRequest: fetchRequest,
managedObjectContext: context.managedObjectContext,

View File

@ -63,10 +63,11 @@ class MainTabBarController: UITabBarController {
let _viewController = ProfileViewController()
_viewController.context = context
_viewController.coordinator = coordinator
_viewController.viewModel = MeProfileViewModel(context: context)
viewController = _viewController
}
viewController.title = self.title
return UINavigationController(rootViewController: viewController)
return AdaptiveStatusBarStyleNavigationController(rootViewController: viewController)
}
}
@ -84,6 +85,11 @@ class MainTabBarController: UITabBarController {
extension MainTabBarController {
open override var childForStatusBarStyle: UIViewController? {
return selectedViewController
}
override func viewDidLoad() {
super.viewDidLoad()
@ -100,9 +106,9 @@ extension MainTabBarController {
selectedIndex = 0
// TODO: custom accent color
let tabBarAppearance = UITabBarAppearance()
tabBarAppearance.configureWithDefaultBackground()
tabBar.standardAppearance = tabBarAppearance
// let tabBarAppearance = UITabBarAppearance()
// tabBarAppearance.configureWithDefaultBackground()
// tabBar.standardAppearance = tabBarAppearance
context.apiService.error
.receive(on: DispatchQueue.main)
@ -151,7 +157,7 @@ extension MainTabBarController {
.store(in: &disposeBag)
#if DEBUG
// selectedIndex = 1
// selectedIndex = 3
#endif
}

View File

@ -66,6 +66,10 @@ final class MastodonConfirmEmailViewController: UIViewController, NeedsDependenc
}
extension MastodonConfirmEmailViewController {
override var preferredStatusBarStyle: UIStatusBarStyle {
return .darkContent
}
override func viewDidLoad() {

View File

@ -56,6 +56,10 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency
extension MastodonPickServerViewController {
override var preferredStatusBarStyle: UIStatusBarStyle {
return .darkContent
}
override func viewDidLoad() {
super.viewDidLoad()

View File

@ -221,6 +221,10 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O
extension MastodonRegisterViewController {
override var preferredStatusBarStyle: UIStatusBarStyle {
return .darkContent
}
override func viewDidLoad() {
super.viewDidLoad()

View File

@ -40,6 +40,11 @@ final class MastodonResendEmailViewController: UIViewController, NeedsDependency
}
extension MastodonResendEmailViewController {
override var preferredStatusBarStyle: UIStatusBarStyle {
return .darkContent
}
override func viewDidLoad() {
super.viewDidLoad()
@ -59,6 +64,7 @@ extension MastodonResendEmailViewController {
webView.load(request)
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: resendEmail via: %s", (#file as NSString).lastPathComponent, #line, #function, viewModel.resendEmailURL.debugDescription)
}
}
extension MastodonResendEmailViewController {

View File

@ -82,6 +82,10 @@ final class MastodonServerRulesViewController: UIViewController, NeedsDependency
extension MastodonServerRulesViewController {
override var preferredStatusBarStyle: UIStatusBarStyle {
return .darkContent
}
override func viewDidLoad() {
super.viewDidLoad()

View File

@ -64,6 +64,10 @@ final class WelcomeViewController: UIViewController, NeedsDependency {
extension WelcomeViewController {
override var preferredStatusBarStyle: UIStatusBarStyle {
return .darkContent
}
override func viewDidLoad() {
super.viewDidLoad()

View File

@ -0,0 +1,17 @@
//
// CachedProfileViewModel.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-31.
//
import Foundation
import CoreDataStack
final class CachedProfileViewModel: ProfileViewModel {
convenience init(context: AppContext, mastodonUser: MastodonUser) {
self.init(context: context, optionalMastodonUser: mastodonUser)
}
}

View File

@ -0,0 +1,142 @@
//
// ProfileHeaderViewController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-29.
//
import os.log
import UIKit
protocol ProfileHeaderViewControllerDelegate: class {
func profileHeaderViewController(_ viewController: ProfileHeaderViewController, viewLayoutDidUpdate view: UIView)
func profileHeaderViewController(_ viewController: ProfileHeaderViewController, pageSegmentedControlValueChanged segmentedControl: UISegmentedControl, selectedSegmentIndex index: Int)
}
final class ProfileHeaderViewController: UIViewController {
static let segmentedControlHeight: CGFloat = 32
static let segmentedControlMarginHeight: CGFloat = 20
static let headerMinHeight: CGFloat = segmentedControlHeight + 2 * segmentedControlMarginHeight
weak var delegate: ProfileHeaderViewControllerDelegate?
let profileBannerView = ProfileHeaderView()
let pageSegmentedControl: UISegmentedControl = {
let segmenetedControl = UISegmentedControl(items: ["A", "B"])
segmenetedControl.selectedSegmentIndex = 0
return segmenetedControl
}()
private var isBannerPinned = false
private var bottomShadowAlpha: CGFloat = 0.0
private var isAdjustBannerImageViewForSafeAreaInset = false
private var containerSafeAreaInset: UIEdgeInsets = .zero
deinit {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension ProfileHeaderViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
profileBannerView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(profileBannerView)
NSLayoutConstraint.activate([
profileBannerView.topAnchor.constraint(equalTo: view.topAnchor),
profileBannerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
profileBannerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
])
profileBannerView.preservesSuperviewLayoutMargins = true
pageSegmentedControl.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(pageSegmentedControl)
NSLayoutConstraint.activate([
pageSegmentedControl.topAnchor.constraint(equalTo: profileBannerView.bottomAnchor, constant: ProfileHeaderViewController.segmentedControlMarginHeight),
pageSegmentedControl.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor),
pageSegmentedControl.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor),
view.bottomAnchor.constraint(equalTo: pageSegmentedControl.bottomAnchor, constant: ProfileHeaderViewController.segmentedControlMarginHeight),
pageSegmentedControl.heightAnchor.constraint(equalToConstant: ProfileHeaderViewController.segmentedControlHeight).priority(.defaultHigh),
])
pageSegmentedControl.addTarget(self, action: #selector(ProfileHeaderViewController.pageSegmentedControlValueChanged(_:)), for: .valueChanged)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if !isAdjustBannerImageViewForSafeAreaInset {
isAdjustBannerImageViewForSafeAreaInset = true
profileBannerView.bannerImageView.frame.origin.y = -containerSafeAreaInset.top
profileBannerView.bannerImageView.frame.size.height += containerSafeAreaInset.top
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
delegate?.profileHeaderViewController(self, viewLayoutDidUpdate: view)
view.layer.setupShadow(color: UIColor.black.withAlphaComponent(0.12), alpha: Float(bottomShadowAlpha), x: 0, y: 2, blur: 2, spread: 0, roundedRect: view.bounds, byRoundingCorners: .allCorners, cornerRadii: .zero)
}
}
extension ProfileHeaderViewController {
@objc private func pageSegmentedControlValueChanged(_ sender: UISegmentedControl) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: selectedSegmentIndex: %ld", ((#file as NSString).lastPathComponent), #line, #function, sender.selectedSegmentIndex)
delegate?.profileHeaderViewController(self, pageSegmentedControlValueChanged: sender, selectedSegmentIndex: sender.selectedSegmentIndex)
}
}
extension ProfileHeaderViewController {
func updateHeaderContainerSafeAreaInset(_ inset: UIEdgeInsets) {
containerSafeAreaInset = inset
}
private func updateHeaderBottomShadow(progress: CGFloat) {
let alpha = min(max(0, 10 * progress - 9), 1)
if bottomShadowAlpha != alpha {
bottomShadowAlpha = alpha
view.setNeedsLayout()
}
}
func updateHeaderScrollProgress(_ progress: CGFloat) {
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: progress: %.2f", ((#file as NSString).lastPathComponent), #line, #function, progress)
updateHeaderBottomShadow(progress: progress)
let bannerImageView = profileBannerView.bannerImageView
guard bannerImageView.bounds != .zero else {
// wait layout finish
return
}
let bannerContainerInWindow = profileBannerView.convert(profileBannerView.bannerContainerView.frame, to: nil)
let bannerContainerBottomOffset = bannerContainerInWindow.origin.y + bannerContainerInWindow.height
if bannerContainerInWindow.origin.y > containerSafeAreaInset.top {
bannerImageView.frame.origin.y = -bannerContainerInWindow.origin.y
bannerImageView.frame.size.height = bannerContainerInWindow.origin.y + bannerContainerInWindow.size.height
} else if bannerContainerBottomOffset < containerSafeAreaInset.top {
bannerImageView.frame.origin.y = -containerSafeAreaInset.top
let bannerImageHeight = bannerContainerInWindow.size.height + containerSafeAreaInset.top + (containerSafeAreaInset.top - bannerContainerBottomOffset)
bannerImageView.frame.size.height = bannerImageHeight
} else {
bannerImageView.frame.origin.y = -containerSafeAreaInset.top
bannerImageView.frame.size.height = bannerContainerInWindow.size.height + containerSafeAreaInset.top
}
// TODO: handle titleView
}
}

View File

@ -0,0 +1,100 @@
//
// ProfileFieldView.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-30.
//
import UIKit
import ActiveLabel
final class ProfileFieldView: UIView {
let titleLabel: UILabel = {
let label = UILabel()
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold))
label.textColor = Asset.Colors.Label.primary.color
label.text = "Title"
return label
}()
let valueActiveLabel: ActiveLabel = {
let label = ActiveLabel(style: .profileField)
label.configure(content: "value")
return label
}()
let topSeparatorLine = UIView.separatorLine
let bottomSeparatorLine = UIView.separatorLine
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension ProfileFieldView {
private func _init() {
titleLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(titleLabel)
NSLayoutConstraint.activate([
titleLabel.topAnchor.constraint(equalTo: topAnchor),
titleLabel.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor),
titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
titleLabel.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh),
])
titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
valueActiveLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(valueActiveLabel)
NSLayoutConstraint.activate([
valueActiveLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
valueActiveLabel.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor),
valueActiveLabel.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor),
])
valueActiveLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal)
topSeparatorLine.translatesAutoresizingMaskIntoConstraints = false
addSubview(topSeparatorLine)
NSLayoutConstraint.activate([
topSeparatorLine.topAnchor.constraint(equalTo: topAnchor),
topSeparatorLine.leadingAnchor.constraint(equalTo: leadingAnchor),
topSeparatorLine.trailingAnchor.constraint(equalTo: trailingAnchor),
topSeparatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: self)).priority(.defaultHigh),
])
bottomSeparatorLine.translatesAutoresizingMaskIntoConstraints = false
addSubview(bottomSeparatorLine)
NSLayoutConstraint.activate([
bottomSeparatorLine.bottomAnchor.constraint(equalTo: bottomAnchor),
bottomSeparatorLine.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor),
bottomSeparatorLine.trailingAnchor.constraint(equalTo: trailingAnchor),
bottomSeparatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: self)).priority(.defaultHigh),
])
}
}
#if canImport(SwiftUI) && DEBUG
import SwiftUI
struct ProfileFieldView_Previews: PreviewProvider {
static var previews: some View {
UIViewPreview(width: 375) {
let filedView = ProfileFieldView()
filedView.valueActiveLabel.configure(field: "https://mastodon.online")
return filedView
}
.previewLayout(.fixed(width: 375, height: 100))
}
}
#endif

View File

@ -0,0 +1,71 @@
//
// ProfileFriendshipActionButton.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-30.
//
import UIKit
final class ProfileFriendshipActionButton: RoundedEdgesButton {
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension ProfileFriendshipActionButton {
private func _init() {
configure(state: .follow)
}
}
extension ProfileFriendshipActionButton {
enum State {
case follow
case following
case blocked
case muted
case edit
case editing
var title: String {
switch self {
case .follow: return L10n.Common.Controls.Firendship.follow
case .following: return L10n.Common.Controls.Firendship.following
case .blocked: return L10n.Common.Controls.Firendship.blocked
case .muted: return L10n.Common.Controls.Firendship.muted
case .edit: return L10n.Common.Controls.Firendship.editInfo
case .editing: return L10n.Common.Controls.Actions.done
}
}
var backgroundColor: UIColor {
switch self {
case .follow: return Asset.Colors.Button.normal.color
case .following: return Asset.Colors.Button.normal.color
case .blocked: return Asset.Colors.Background.danger.color
case .muted: return Asset.Colors.Background.alertYellow.color
case .edit: return Asset.Colors.Button.normal.color
case .editing: return Asset.Colors.Button.normal.color
}
}
}
private func configure(state: State) {
setTitle(state.title, for: .normal)
setTitleColor(.white, for: .normal)
setTitleColor(UIColor.white.withAlphaComponent(0.5), for: .highlighted)
setBackgroundImage(.placeholder(color: state.backgroundColor), for: .normal)
setBackgroundImage(.placeholder(color: state.backgroundColor.withAlphaComponent(0.5)), for: .highlighted)
setBackgroundImage(.placeholder(color: state.backgroundColor.withAlphaComponent(0.5)), for: .disabled)
}
}

View File

@ -0,0 +1,247 @@
//
// ProfileBannerView.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-29.
//
import os.log
import UIKit
import ActiveLabel
protocol ProfileHeaderViewDelegate: class {
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity)
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView)
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed dwingDashboardMeterView: ProfileStatusDashboardMeterView)
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed dwersDashboardMeterView: ProfileStatusDashboardMeterView)
}
final class ProfileHeaderView: UIView {
static let avatarImageViewSize = CGSize(width: 56, height: 56)
static let avatarImageViewCornerRadius: CGFloat = 6
static let friendshipActionButtonSize = CGSize(width: 108, height: 34)
weak var delegate: ProfileHeaderViewDelegate?
let bannerContainerView = UIView()
let bannerImageView: UIImageView = {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFill
imageView.image = .placeholder(color: .systemGray)
imageView.layer.masksToBounds = true
return imageView
}()
let avatarImageView: UIImageView = {
let imageView = UIImageView()
let placeholderImage = UIImage
.placeholder(size: ProfileHeaderView.avatarImageViewSize, color: Asset.Colors.Background.systemGroupedBackground.color)
.af.imageRounded(withCornerRadius: ProfileHeaderView.avatarImageViewCornerRadius, divideRadiusByImageScale: false)
imageView.image = placeholderImage
return imageView
}()
let nameLabel: UILabel = {
let label = UILabel()
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold))
label.adjustsFontSizeToFitWidth = true
label.minimumScaleFactor = 0.5
label.textColor = .white
label.text = "Alice"
label.applyShadow(color: UIColor.black.withAlphaComponent(0.2), alpha: 0.5, x: 0, y: 2, blur: 2, spread: 0)
return label
}()
let usernameLabel: UILabel = {
let label = UILabel()
label.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 15, weight: .regular))
label.adjustsFontSizeToFitWidth = true
label.minimumScaleFactor = 0.5
label.textColor = .white
label.text = "@alice"
label.applyShadow(color: UIColor.black.withAlphaComponent(0.2), alpha: 0.5, x: 0, y: 2, blur: 2, spread: 0)
return label
}()
let statusDashboardView = ProfileStatusDashboardView()
let friendshipActionButton = ProfileFriendshipActionButton()
let bioContainerView = UIView()
let fieldContainerStackView = UIStackView()
let bioActiveLabel = ActiveLabel(style: .default)
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension ProfileHeaderView {
private func _init() {
backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
// banner
bannerContainerView.translatesAutoresizingMaskIntoConstraints = false
bannerContainerView.preservesSuperviewLayoutMargins = true
addSubview(bannerContainerView)
NSLayoutConstraint.activate([
bannerContainerView.topAnchor.constraint(equalTo: topAnchor),
bannerContainerView.leadingAnchor.constraint(equalTo: leadingAnchor),
trailingAnchor.constraint(equalTo: bannerContainerView.trailingAnchor),
readableContentGuide.widthAnchor.constraint(equalTo: bannerContainerView.heightAnchor, multiplier: 3), // set height to 1/3 of readable frame width
])
bannerImageView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
bannerImageView.frame = bannerContainerView.bounds
bannerContainerView.addSubview(bannerImageView)
// avatar
avatarImageView.translatesAutoresizingMaskIntoConstraints = false
bannerContainerView.addSubview(avatarImageView)
NSLayoutConstraint.activate([
avatarImageView.leadingAnchor.constraint(equalTo: bannerContainerView.readableContentGuide.leadingAnchor),
bannerContainerView.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: 20),
avatarImageView.widthAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.width).priority(.required - 1),
avatarImageView.heightAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.height).priority(.required - 1),
])
// name container: [display name | username]
let nameContainerStackView = UIStackView()
nameContainerStackView.preservesSuperviewLayoutMargins = true
nameContainerStackView.axis = .vertical
nameContainerStackView.spacing = 0
nameContainerStackView.translatesAutoresizingMaskIntoConstraints = false
addSubview(nameContainerStackView)
NSLayoutConstraint.activate([
nameContainerStackView.leadingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: 12),
nameContainerStackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor),
nameContainerStackView.centerYAnchor.constraint(equalTo: avatarImageView.centerYAnchor),
])
nameContainerStackView.addArrangedSubview(nameLabel)
nameContainerStackView.addArrangedSubview(usernameLabel)
// meta container: [dashboard container | bio container | field container]
let metaContainerStackView = UIStackView()
metaContainerStackView.spacing = 16
metaContainerStackView.axis = .vertical
metaContainerStackView.preservesSuperviewLayoutMargins = true
metaContainerStackView.translatesAutoresizingMaskIntoConstraints = false
addSubview(metaContainerStackView)
NSLayoutConstraint.activate([
metaContainerStackView.topAnchor.constraint(equalTo: bannerContainerView.bottomAnchor, constant: 13),
metaContainerStackView.leadingAnchor.constraint(equalTo: leadingAnchor),
metaContainerStackView.trailingAnchor.constraint(equalTo: trailingAnchor),
metaContainerStackView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
// dashboard container: [dashboard | friendship action button]
let dashboardContainerView = UIView()
dashboardContainerView.preservesSuperviewLayoutMargins = true
metaContainerStackView.addArrangedSubview(dashboardContainerView)
statusDashboardView.translatesAutoresizingMaskIntoConstraints = false
dashboardContainerView.addSubview(statusDashboardView)
NSLayoutConstraint.activate([
statusDashboardView.topAnchor.constraint(equalTo: dashboardContainerView.topAnchor),
statusDashboardView.leadingAnchor.constraint(equalTo: dashboardContainerView.readableContentGuide.leadingAnchor),
statusDashboardView.bottomAnchor.constraint(equalTo: dashboardContainerView.bottomAnchor),
])
friendshipActionButton.translatesAutoresizingMaskIntoConstraints = false
dashboardContainerView.addSubview(friendshipActionButton)
NSLayoutConstraint.activate([
friendshipActionButton.topAnchor.constraint(equalTo: dashboardContainerView.topAnchor),
friendshipActionButton.leadingAnchor.constraint(greaterThanOrEqualTo: statusDashboardView.trailingAnchor, constant: 8),
friendshipActionButton.trailingAnchor.constraint(equalTo: dashboardContainerView.readableContentGuide.trailingAnchor),
friendshipActionButton.widthAnchor.constraint(equalToConstant: ProfileHeaderView.friendshipActionButtonSize.width).priority(.defaultHigh),
friendshipActionButton.heightAnchor.constraint(equalToConstant: ProfileHeaderView.friendshipActionButtonSize.height).priority(.defaultHigh),
])
bioContainerView.preservesSuperviewLayoutMargins = true
metaContainerStackView.addArrangedSubview(bioContainerView)
bioActiveLabel.translatesAutoresizingMaskIntoConstraints = false
bioContainerView.addSubview(bioActiveLabel)
NSLayoutConstraint.activate([
bioActiveLabel.topAnchor.constraint(equalTo: bioContainerView.topAnchor),
bioActiveLabel.leadingAnchor.constraint(equalTo: bioContainerView.readableContentGuide.leadingAnchor),
bioActiveLabel.trailingAnchor.constraint(equalTo: bioContainerView.readableContentGuide.trailingAnchor),
bioActiveLabel.bottomAnchor.constraint(equalTo: bioContainerView.bottomAnchor),
])
fieldContainerStackView.preservesSuperviewLayoutMargins = true
metaContainerStackView.addSubview(fieldContainerStackView)
bringSubviewToFront(bannerContainerView)
bringSubviewToFront(nameContainerStackView)
bioActiveLabel.delegate = self
}
}
// MARK: - ActiveLabelDelegate
extension ProfileHeaderView: ActiveLabelDelegate {
func activeLabel(_ activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: select entity: %s", ((#file as NSString).lastPathComponent), #line, #function, entity.primaryText)
delegate?.profileHeaderView(self, activeLabel: activeLabel, entityDidPressed: entity)
}
}
// MARK: - ProfileStatusDashboardViewDelegate
extension ProfileHeaderView: ProfileStatusDashboardViewDelegate {
func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) {
delegate?.profileHeaderView(self, profileStatusDashboardView: dashboardView, postDashboardMeterViewDidPressed: dashboardMeterView)
}
func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) {
delegate?.profileHeaderView(self, profileStatusDashboardView: dashboardView, followingDashboardMeterViewDidPressed: dashboardMeterView)
}
func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) {
delegate?.profileHeaderView(self, profileStatusDashboardView: dashboardView, followersDashboardMeterViewDidPressed: dashboardMeterView)
}
}
// MARK: - AvatarConfigurableView
extension ProfileHeaderView: AvatarConfigurableView {
static var configurableAvatarImageSize: CGSize { avatarImageViewSize }
static var configurableAvatarImageCornerRadius: CGFloat { avatarImageViewCornerRadius }
var configurableAvatarImageView: UIImageView? { return avatarImageView }
var configurableAvatarButton: UIButton? { return nil }
}
#if DEBUG
import SwiftUI
struct ProfileHeaderView_Previews: PreviewProvider {
static var previews: some View {
Group {
UIViewPreview(width: 375) {
let banner = ProfileHeaderView()
banner.bannerImageView.image = UIImage(named: "lucas-ludwig")
return banner
}
.previewLayout(.fixed(width: 375, height: 800))
UIViewPreview(width: 375) {
let banner = ProfileHeaderView()
//banner.bannerImageView.image = UIImage(named: "peter-luo")
return banner
}
.preferredColorScheme(.dark)
.previewLayout(.fixed(width: 375, height: 800))
}
}
}
#endif

View File

@ -0,0 +1,79 @@
//
// ProfileStatusDashboardMeterView.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-30.
//
import UIKit
final class ProfileStatusDashboardMeterView: UIView {
let numberLabel: UILabel = {
let label = UILabel()
label.font = {
let font = UIFont.systemFont(ofSize: 20, weight: .semibold)
return font.fontDescriptor.withDesign(.rounded).flatMap {
UIFont(descriptor: $0, size: 20)
} ?? font
}()
label.textColor = Asset.Colors.Label.primary.color
label.text = "999"
label.textAlignment = .center
return label
}()
let textLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 13, weight: .regular)
label.textColor = Asset.Colors.Label.primary.color
label.text = L10n.Scene.Profile.Dashboard.posts
label.textAlignment = .center
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension ProfileStatusDashboardMeterView {
private func _init() {
numberLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(numberLabel)
NSLayoutConstraint.activate([
numberLabel.topAnchor.constraint(equalTo: topAnchor),
numberLabel.leadingAnchor.constraint(equalTo: leadingAnchor),
trailingAnchor.constraint(equalTo: numberLabel.trailingAnchor),
])
textLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(textLabel)
NSLayoutConstraint.activate([
textLabel.topAnchor.constraint(equalTo: numberLabel.bottomAnchor),
textLabel.leadingAnchor.constraint(equalTo: leadingAnchor),
trailingAnchor.constraint(equalTo: textLabel.trailingAnchor),
bottomAnchor.constraint(equalTo: textLabel.bottomAnchor),
])
}
}
#if DEBUG
import SwiftUI
struct ProfileStatusDashboardMeterView_Previews: PreviewProvider {
static var previews: some View {
UIViewPreview(width: 54) {
ProfileStatusDashboardMeterView()
}
.previewLayout(.fixed(width: 54, height: 41))
}
}
#endif

View File

@ -0,0 +1,103 @@
//
// ProfileStatusDashboardView.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-30.
//
import os.log
import UIKit
protocol ProfileStatusDashboardViewDelegate: class {
func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView)
func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView)
func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView)
}
final class ProfileStatusDashboardView: UIView {
let postDashboardMeterView = ProfileStatusDashboardMeterView()
let followingDashboardMeterView = ProfileStatusDashboardMeterView()
let followersDashboardMeterView = ProfileStatusDashboardMeterView()
weak var delegate: ProfileStatusDashboardViewDelegate?
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension ProfileStatusDashboardView {
private func _init() {
let containerStackView = UIStackView()
containerStackView.translatesAutoresizingMaskIntoConstraints = false
addSubview(containerStackView)
NSLayoutConstraint.activate([
containerStackView.topAnchor.constraint(equalTo: topAnchor),
containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor),
trailingAnchor.constraint(equalTo: containerStackView.trailingAnchor),
bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor),
containerStackView.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh),
])
let spacing: CGFloat = 16
containerStackView.spacing = spacing
containerStackView.axis = .horizontal
containerStackView.distribution = .fillEqually
containerStackView.alignment = .top
containerStackView.addArrangedSubview(postDashboardMeterView)
containerStackView.setCustomSpacing(spacing - 2, after: postDashboardMeterView)
containerStackView.addArrangedSubview(followingDashboardMeterView)
containerStackView.setCustomSpacing(spacing + 2, after: followingDashboardMeterView)
containerStackView.addArrangedSubview(followersDashboardMeterView)
postDashboardMeterView.textLabel.text = L10n.Scene.Profile.Dashboard.posts
followingDashboardMeterView.textLabel.text = L10n.Scene.Profile.Dashboard.following
followersDashboardMeterView.textLabel.text = L10n.Scene.Profile.Dashboard.followers
[postDashboardMeterView, followingDashboardMeterView, followersDashboardMeterView].forEach { meterView in
let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer
tapGestureRecognizer.addTarget(self, action: #selector(ProfileStatusDashboardView.tapGestureRecognizerHandler(_:)))
meterView.addGestureRecognizer(tapGestureRecognizer)
}
}
}
extension ProfileStatusDashboardView {
@objc private func tapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
guard let sourceView = sender.view as? ProfileStatusDashboardMeterView else {
assertionFailure()
return
}
if sourceView === postDashboardMeterView {
delegate?.profileStatusDashboardView(self, postDashboardMeterViewDidPressed: sourceView)
} else if sourceView === followingDashboardMeterView {
delegate?.profileStatusDashboardView(self, followingDashboardMeterViewDidPressed: sourceView)
} else if sourceView === followersDashboardMeterView {
delegate?.profileStatusDashboardView(self, followersDashboardMeterViewDidPressed: sourceView)
}
}
}
#if DEBUG
import SwiftUI
struct ProfileBannerStatusView_Previews: PreviewProvider {
static var previews: some View {
UIViewPreview(width: 375) {
ProfileStatusDashboardView()
}
.previewLayout(.fixed(width: 375, height: 100))
}
}
#endif

View File

@ -0,0 +1,33 @@
//
// MeProfileViewModel.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-30.
//
import os.log
import UIKit
import Combine
import CoreData
import CoreDataStack
import MastodonSDK
final class MeProfileViewModel: ProfileViewModel {
init(context: AppContext) {
super.init(
context: context,
optionalMastodonUser: context.authenticationService.activeMastodonAuthentication.value?.user
)
self.currentMastodonUser
.sink { [weak self] currentMastodonUser in
os_log("%{public}s[%{public}ld], %{public}s: current active twitter user: %s", ((#file as NSString).lastPathComponent), #line, #function, currentMastodonUser?.username ?? "<nil>")
guard let self = self else { return }
self.mastodonUser.value = currentMastodonUser
}
.store(in: &disposeBag)
}
}

View File

@ -5,20 +5,672 @@
// Created by MainasuK Cirno on 2021-2-23.
//
import os.log
import UIKit
import Combine
import ActiveLabel
final class ProfileViewController: UIViewController, NeedsDependency {
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
var disposeBag = Set<AnyCancellable>()
var viewModel: ProfileViewModel!
private var preferredStatusBarStyleForBanner: UIStatusBarStyle = .lightContent {
didSet {
setNeedsStatusBarAppearanceUpdate()
}
}
let refreshControl: UIRefreshControl = {
let refreshControl = UIRefreshControl()
refreshControl.tintColor = .label
return refreshControl
}()
let containerScrollView: UIScrollView = {
let scrollView = UIScrollView()
scrollView.scrollsToTop = false
scrollView.showsVerticalScrollIndicator = false
scrollView.preservesSuperviewLayoutMargins = true
scrollView.delaysContentTouches = false
return scrollView
}()
let overlayScrollView: UIScrollView = {
let scrollView = UIScrollView()
scrollView.showsVerticalScrollIndicator = false
scrollView.backgroundColor = .clear
scrollView.delaysContentTouches = false
return scrollView
}()
private(set) lazy var profileSegmentedViewController = ProfileSegmentedViewController()
private(set) lazy var profileHeaderViewController = ProfileHeaderViewController()
private var profileBannerImageViewLayoutConstraint: NSLayoutConstraint!
private var contentOffsets: [Int: CGFloat] = [:]
var currentPostTimelineTableViewContentSizeObservation: NSKeyValueObservation?
deinit {
os_log("%{public}s[%{public}ld], %{public}s: deinit", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension ProfileViewController {
override func viewDidLoad() {
super.viewDidLoad()
func observeTableViewContentSize(scrollView: UIScrollView) -> NSKeyValueObservation {
updateOverlayScrollViewContentSize(scrollView: scrollView)
return scrollView.observe(\.contentSize, options: .new) { scrollView, change in
self.updateOverlayScrollViewContentSize(scrollView: scrollView)
}
}
func updateOverlayScrollViewContentSize(scrollView: UIScrollView) {
let bottomPageHeight = max(scrollView.contentSize.height, self.containerScrollView.frame.height - ProfileHeaderViewController.headerMinHeight - self.containerScrollView.safeAreaInsets.bottom)
let headerViewHeight: CGFloat = profileHeaderViewController.view.frame.height
let contentSize = CGSize(
width: self.containerScrollView.contentSize.width,
height: bottomPageHeight + headerViewHeight
)
self.overlayScrollView.contentSize = contentSize
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: contentSize: %s", ((#file as NSString).lastPathComponent), #line, #function, contentSize.debugDescription)
}
}
extension ProfileViewController {
override var preferredStatusBarStyle: UIStatusBarStyle {
return preferredStatusBarStyleForBanner
}
override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
profileHeaderViewController.updateHeaderContainerSafeAreaInset(view.safeAreaInsets)
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
let barAppearance = UINavigationBarAppearance()
barAppearance.configureWithTransparentBackground()
navigationItem.standardAppearance = barAppearance
navigationItem.compactAppearance = barAppearance
navigationItem.scrollEdgeAppearance = barAppearance
navigationItem.titleView = UIView()
// if navigationController?.viewControllers.first == self {
// navigationItem.leftBarButtonItem = avatarBarButtonItem
// }
// avatarBarButtonItem.avatarButton.addTarget(self, action: #selector(ProfileViewController.avatarButtonPressed(_:)), for: .touchUpInside)
// unmuteMenuBarButtonItem.target = self
// unmuteMenuBarButtonItem.action = #selector(ProfileViewController.unmuteBarButtonItemPressed(_:))
// Publishers.CombineLatest4(
// viewModel.muted.eraseToAnyPublisher(),
// viewModel.blocked.eraseToAnyPublisher(),
// viewModel.twitterUser.eraseToAnyPublisher(),
// context.authenticationService.activeTwitterAuthenticationBox.eraseToAnyPublisher()
// )
// .receive(on: DispatchQueue.main)
// .sink { [weak self] muted, blocked, twitterUser, activeTwitterAuthenticationBox in
// guard let self = self else { return }
// guard let twitterUser = twitterUser,
// let activeTwitterAuthenticationBox = activeTwitterAuthenticationBox,
// twitterUser.id != activeTwitterAuthenticationBox.twitterUserID else {
// self.navigationItem.rightBarButtonItems = []
// return
// }
//
// if #available(iOS 14.0, *) {
// self.moreMenuBarButtonItem.target = nil
// self.moreMenuBarButtonItem.action = nil
// self.moreMenuBarButtonItem.menu = UserProviderFacade.createMenuForUser(
// twitterUser: twitterUser,
// muted: muted,
// blocked: blocked,
// dependency: self
// )
// } else {
// // no menu supports for early version
// self.moreMenuBarButtonItem.target = self
// self.moreMenuBarButtonItem.action = #selector(ProfileViewController.moreMenuBarButtonItemPressed(_:))
// }
//
// var rightBarButtonItems: [UIBarButtonItem] = [self.moreMenuBarButtonItem]
// if muted {
// rightBarButtonItems.append(self.unmuteMenuBarButtonItem)
// }
//
// self.navigationItem.rightBarButtonItems = rightBarButtonItems
// }
// .store(in: &disposeBag)
overlayScrollView.refreshControl = refreshControl
refreshControl.addTarget(self, action: #selector(ProfileViewController.refreshControlValueChanged(_:)), for: .valueChanged)
// drawerSidebarTransitionController = DrawerSidebarTransitionController(drawerSidebarTransitionableViewController: self)
let postsUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter())
viewModel.domain.assign(to: \.value, on: postsUserTimelineViewModel.domain).store(in: &disposeBag)
viewModel.userID.assign(to: \.value, on: postsUserTimelineViewModel.userID).store(in: &disposeBag)
let repliesUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter(excludeReplies: true))
viewModel.domain.assign(to: \.value, on: repliesUserTimelineViewModel.domain).store(in: &disposeBag)
viewModel.userID.assign(to: \.value, on: repliesUserTimelineViewModel.userID).store(in: &disposeBag)
let mediaUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter(onlyMedia: true))
viewModel.domain.assign(to: \.value, on: mediaUserTimelineViewModel.domain).store(in: &disposeBag)
viewModel.userID.assign(to: \.value, on: mediaUserTimelineViewModel.userID).store(in: &disposeBag)
profileSegmentedViewController.pagingViewController.viewModel = {
let profilePagingViewModel = ProfilePagingViewModel(
postsUserTimelineViewModel: postsUserTimelineViewModel,
repliesUserTimelineViewModel: repliesUserTimelineViewModel,
mediaUserTimelineViewModel: mediaUserTimelineViewModel
)
profilePagingViewModel.viewControllers.forEach { viewController in
if let viewController = viewController as? NeedsDependency {
viewController.context = context
viewController.coordinator = coordinator
}
}
return profilePagingViewModel
}()
profileHeaderViewController.pageSegmentedControl.removeAllSegments()
profileSegmentedViewController.pagingViewController.viewModel.barItems.forEach { item in
let index = profileHeaderViewController.pageSegmentedControl.numberOfSegments
profileHeaderViewController.pageSegmentedControl.insertSegment(withTitle: item.title, at: index, animated: false)
}
profileHeaderViewController.pageSegmentedControl.selectedSegmentIndex = 0
overlayScrollView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(overlayScrollView)
NSLayoutConstraint.activate([
overlayScrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor),
overlayScrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor),
view.trailingAnchor.constraint(equalTo: overlayScrollView.frameLayoutGuide.trailingAnchor),
view.bottomAnchor.constraint(equalTo: overlayScrollView.frameLayoutGuide.bottomAnchor),
overlayScrollView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor),
])
containerScrollView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(containerScrollView)
NSLayoutConstraint.activate([
containerScrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor),
containerScrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor),
view.trailingAnchor.constraint(equalTo: containerScrollView.frameLayoutGuide.trailingAnchor),
view.bottomAnchor.constraint(equalTo: containerScrollView.frameLayoutGuide.bottomAnchor),
containerScrollView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor),
])
// add segmented list
addChild(profileSegmentedViewController)
profileSegmentedViewController.view.translatesAutoresizingMaskIntoConstraints = false
containerScrollView.addSubview(profileSegmentedViewController.view)
profileSegmentedViewController.didMove(toParent: self)
NSLayoutConstraint.activate([
profileSegmentedViewController.view.leadingAnchor.constraint(equalTo: containerScrollView.contentLayoutGuide.leadingAnchor),
profileSegmentedViewController.view.trailingAnchor.constraint(equalTo: containerScrollView.contentLayoutGuide.trailingAnchor),
profileSegmentedViewController.view.bottomAnchor.constraint(equalTo: containerScrollView.contentLayoutGuide.bottomAnchor),
profileSegmentedViewController.view.heightAnchor.constraint(equalTo: containerScrollView.frameLayoutGuide.heightAnchor),
])
// add header
addChild(profileHeaderViewController)
profileHeaderViewController.view.translatesAutoresizingMaskIntoConstraints = false
containerScrollView.addSubview(profileHeaderViewController.view)
profileHeaderViewController.didMove(toParent: self)
NSLayoutConstraint.activate([
profileHeaderViewController.view.topAnchor.constraint(equalTo: containerScrollView.topAnchor),
profileHeaderViewController.view.leadingAnchor.constraint(equalTo: containerScrollView.contentLayoutGuide.leadingAnchor),
containerScrollView.contentLayoutGuide.trailingAnchor.constraint(equalTo: profileHeaderViewController.view.trailingAnchor),
profileSegmentedViewController.view.topAnchor.constraint(equalTo: profileHeaderViewController.view.bottomAnchor),
])
containerScrollView.addGestureRecognizer(overlayScrollView.panGestureRecognizer)
overlayScrollView.layer.zPosition = .greatestFiniteMagnitude // make vision top-most
overlayScrollView.delegate = self
profileHeaderViewController.delegate = self
profileSegmentedViewController.pagingViewController.pagingDelegate = self
// // add segmented bar to header
// profileSegmentedViewController.pagingViewController.addBar(
// bar,
// dataSource: profileSegmentedViewController.pagingViewController.viewModel,
// at: .custom(view: profileHeaderViewController.view, layout: { bar in
// bar.translatesAutoresizingMaskIntoConstraints = false
// self.profileHeaderViewController.view.addSubview(bar)
// NSLayoutConstraint.activate([
// bar.leadingAnchor.constraint(equalTo: self.profileHeaderViewController.view.leadingAnchor),
// bar.trailingAnchor.constraint(equalTo: self.profileHeaderViewController.view.trailingAnchor),
// bar.bottomAnchor.constraint(equalTo: self.profileHeaderViewController.view.bottomAnchor),
// bar.heightAnchor.constraint(equalToConstant: ProfileHeaderViewController.headerMinHeight).priority(.defaultHigh),
// ])
// })
// )
// bind view model
Publishers.CombineLatest(
viewModel.bannerImageURL.eraseToAnyPublisher(),
viewModel.viewDidAppear.eraseToAnyPublisher()
)
.receive(on: DispatchQueue.main)
.sink { [weak self] bannerImageURL, _ in
guard let self = self else { return }
self.profileHeaderViewController.profileBannerView.bannerImageView.af.cancelImageRequest()
let placeholder = UIImage.placeholder(color: Asset.Colors.Background.systemGroupedBackground.color)
guard let bannerImageURL = bannerImageURL else {
self.profileHeaderViewController.profileBannerView.bannerImageView.image = placeholder
return
}
self.profileHeaderViewController.profileBannerView.bannerImageView.af.setImage(
withURL: bannerImageURL,
placeholderImage: placeholder,
imageTransition: .crossDissolve(0.3),
runImageTransitionIfCached: false,
completion: { [weak self] response in
guard let self = self else { return }
switch response.result {
case .success(let image):
self.viewModel.headerDomainLumaStyle.value = image.domainLumaCoefficientsStyle ?? .dark
case .failure:
break
}
}
)
}
.store(in: &disposeBag)
viewModel.headerDomainLumaStyle
.receive(on: DispatchQueue.main)
.sink { [weak self] style in
guard let self = self else { return }
let textColor: UIColor
let shadowColor: UIColor
switch style {
case .light:
self.preferredStatusBarStyleForBanner = .darkContent
textColor = .black
shadowColor = .white
case .dark:
self.preferredStatusBarStyleForBanner = .lightContent
textColor = .white
shadowColor = .black
default:
self.preferredStatusBarStyleForBanner = .default
textColor = .white
shadowColor = .black
}
self.profileHeaderViewController.profileBannerView.nameLabel.textColor = textColor
self.profileHeaderViewController.profileBannerView.usernameLabel.textColor = textColor
self.profileHeaderViewController.profileBannerView.nameLabel.applyShadow(color: shadowColor, alpha: 0.5, x: 0, y: 2, blur: 2)
self.profileHeaderViewController.profileBannerView.usernameLabel.applyShadow(color: shadowColor, alpha: 0.5, x: 0, y: 2, blur: 2)
}
.store(in: &disposeBag)
Publishers.CombineLatest(
viewModel.avatarImageURL.eraseToAnyPublisher(),
viewModel.viewDidAppear.eraseToAnyPublisher()
)
.receive(on: DispatchQueue.main)
.sink { [weak self] avatarImageURL, _ in
guard let self = self else { return }
self.profileHeaderViewController.profileBannerView.configure(
with: AvatarConfigurableViewConfiguration(avatarImageURL: avatarImageURL)
)
}
.store(in: &disposeBag)
// viewModel.protected
// .map { $0 != true }
// .assign(to: \.isHidden, on: profileHeaderViewController.profileBannerView.lockImageView)
// .store(in: &disposeBag)
viewModel.name
.map { $0 ?? " " }
.receive(on: DispatchQueue.main)
.assign(to: \.text, on: profileHeaderViewController.profileBannerView.nameLabel)
.store(in: &disposeBag)
viewModel.username
.map { username in username.flatMap { "@" + $0 } ?? " " }
.receive(on: DispatchQueue.main)
.assign(to: \.text, on: profileHeaderViewController.profileBannerView.usernameLabel)
.store(in: &disposeBag)
// viewModel.friendship
// .sink { [weak self] friendship in
// guard let self = self else { return }
// let followingButton = self.profileHeaderViewController.profileBannerView.profileBannerInfoActionView.followActionButton
// followingButton.isHidden = friendship == nil
//
// if let friendship = friendship {
// switch friendship {
// case .following: followingButton.style = .following
// case .pending: followingButton.style = .pending
// case .none: followingButton.style = .follow
// }
// }
// }
// .store(in: &disposeBag)
// viewModel.followedBy
// .sink { [weak self] followedBy in
// guard let self = self else { return }
// let followStatusLabel = self.profileHeaderViewController.profileBannerView.profileBannerInfoActionView.followStatusLabel
// followStatusLabel.isHidden = followedBy != true
// }
// .store(in: &disposeBag)
//
viewModel.bioDescription
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] bio in
guard let self = self else { return }
self.profileHeaderViewController.profileBannerView.bioActiveLabel.configure(note: bio ?? "")
})
.store(in: &disposeBag)
// Publishers.CombineLatest(
// viewModel.url.eraseToAnyPublisher(),
// viewModel.suspended.eraseToAnyPublisher()
// )
// .receive(on: DispatchQueue.main)
// .sink { [weak self] url, isSuspended in
// guard let self = self else { return }
// let url = url.flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines) } ?? " "
// self.profileHeaderViewController.profileBannerView.linkButton.setTitle(url, for: .normal)
// let isEmpty = url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
// self.profileHeaderViewController.profileBannerView.linkContainer.isHidden = isEmpty || isSuspended
// }
// .store(in: &disposeBag)
// Publishers.CombineLatest(
// viewModel.location.eraseToAnyPublisher(),
// viewModel.suspended.eraseToAnyPublisher()
// )
// .receive(on: DispatchQueue.main)
// .sink { [weak self] location, isSuspended in
// guard let self = self else { return }
// let location = location.flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines) } ?? " "
// self.profileHeaderViewController.profileBannerView.geoButton.setTitle(location, for: .normal)
// let isEmpty = location.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
// self.profileHeaderViewController.profileBannerView.geoContainer.isHidden = isEmpty || isSuspended
// }
// .store(in: &disposeBag)
viewModel.statusesCount
.sink { [weak self] count in
guard let self = self else { return }
let text = count.flatMap { String($0) } ?? "-"
self.profileHeaderViewController.profileBannerView.statusDashboardView.postDashboardMeterView.numberLabel.text = text
}
.store(in: &disposeBag)
viewModel.followingCount
.sink { [weak self] count in
guard let self = self else { return }
let text = count.flatMap { String($0) } ?? "-"
self.profileHeaderViewController.profileBannerView.statusDashboardView.followingDashboardMeterView.numberLabel.text = text
}
.store(in: &disposeBag)
viewModel.followersCount
.sink { [weak self] count in
guard let self = self else { return }
let text = count.flatMap { String($0) } ?? "-"
self.profileHeaderViewController.profileBannerView.statusDashboardView.followersDashboardMeterView.numberLabel.text = text
}
.store(in: &disposeBag)
// viewModel.followersCount
// .sink { [weak self] count in
// guard let self = self else { return }
// self.profileHeaderViewController.profileBannerView.profileBannerStatusView.followersStatusItemView.countLabel.text = count.flatMap { "\($0)" } ?? "-"
// }
// .store(in: &disposeBag)
// viewModel.listedCount
// .sink { [weak self] count in
// guard let self = self else { return }
// self.profileHeaderViewController.profileBannerView.profileBannerStatusView.listedStatusItemView.countLabel.text = count.flatMap { "\($0)" } ?? "-"
// }
// .store(in: &disposeBag)
// viewModel.suspended
// .receive(on: DispatchQueue.main)
// .sink { [weak self] isSuspended in
// guard let self = self else { return }
// self.profileHeaderViewController.profileBannerView.profileBannerStatusView.isHidden = isSuspended
// self.profileHeaderViewController.profileBannerView.profileBannerInfoActionView.isHidden = isSuspended
// if isSuspended {
// self.profileSegmentedViewController
// .pagingViewController.viewModel
// .profileTweetPostTimelineViewController.viewModel
// .stateMachine
// .enter(UserTimelineViewModel.State.Suspended.self)
// self.profileSegmentedViewController
// .pagingViewController.viewModel
// .profileMediaPostTimelineViewController.viewModel
// .stateMachine
// .enter(UserMediaTimelineViewModel.State.Suspended.self)
// self.profileSegmentedViewController
// .pagingViewController.viewModel
// .profileLikesPostTimelineViewController.viewModel
// .stateMachine
// .enter(UserLikeTimelineViewModel.State.Suspended.self)
// }
// }
// .store(in: &disposeBag)
//
profileHeaderViewController.profileBannerView.delegate = self
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
viewModel.viewDidAppear.send()
// set overlay scroll view initial content size
guard let currentViewController = profileSegmentedViewController.pagingViewController.currentViewController as? ScrollViewContainer else { return }
currentPostTimelineTableViewContentSizeObservation = observeTableViewContentSize(scrollView: currentViewController.scrollView)
currentViewController.scrollView.panGestureRecognizer.require(toFail: overlayScrollView.panGestureRecognizer)
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
currentPostTimelineTableViewContentSizeObservation = nil
}
}
extension ProfileViewController {
@objc private func refreshControlValueChanged(_ sender: UIRefreshControl) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
let currentViewController = profileSegmentedViewController.pagingViewController.currentViewController
if let currentViewController = currentViewController as? UserTimelineViewController {
currentViewController.viewModel.stateMachine.enter(UserTimelineViewModel.State.Reloading.self)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
sender.endRefreshing()
}
}
// @objc private func avatarButtonPressed(_ sender: UIButton) {
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
// coordinator.present(scene: .drawerSidebar, from: self, transition: .custom(transitioningDelegate: drawerSidebarTransitionController))
// }
//
// @objc private func unmuteBarButtonItemPressed(_ sender: UIBarButtonItem) {
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
// guard let twitterUser = viewModel.twitterUser.value else {
// assertionFailure()
// return
// }
//
// UserProviderFacade.toggleMuteUser(
// context: context,
// twitterUser: twitterUser,
// muted: viewModel.muted.value
// )
// .sink { _ in
// // do nothing
// } receiveValue: { _ in
// // do nothing
// }
// .store(in: &disposeBag)
// }
//
// @objc private func moreMenuBarButtonItemPressed(_ sender: UIBarButtonItem) {
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
// guard let twitterUser = viewModel.twitterUser.value else {
// assertionFailure()
// return
// }
//
// let moreMenuAlertController = UserProviderFacade.createMoreMenuAlertControllerForUser(
// twitterUser: twitterUser,
// muted: viewModel.muted.value,
// blocked: viewModel.blocked.value,
// sender: sender,
// dependency: self
// )
// present(moreMenuAlertController, animated: true, completion: nil)
// }
}
// MARK: - UIScrollViewDelegate
extension ProfileViewController: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
contentOffsets[profileSegmentedViewController.pagingViewController.currentIndex!] = scrollView.contentOffset.y
let topMaxContentOffsetY = profileSegmentedViewController.view.frame.minY - ProfileHeaderViewController.headerMinHeight - containerScrollView.safeAreaInsets.top
if scrollView.contentOffset.y < topMaxContentOffsetY {
self.containerScrollView.contentOffset.y = scrollView.contentOffset.y
for postTimelineView in profileSegmentedViewController.pagingViewController.viewModel.viewControllers {
postTimelineView.scrollView.contentOffset.y = 0
}
contentOffsets.removeAll()
} else {
containerScrollView.contentOffset.y = topMaxContentOffsetY
if let customScrollViewContainerController = profileSegmentedViewController.pagingViewController.currentViewController as? ScrollViewContainer {
let contentOffsetY = scrollView.contentOffset.y - containerScrollView.contentOffset.y
customScrollViewContainerController.scrollView.contentOffset.y = contentOffsetY
}
}
// elastically banner image
let headerScrollProgress = containerScrollView.contentOffset.y / topMaxContentOffsetY
profileHeaderViewController.updateHeaderScrollProgress(headerScrollProgress)
}
}
// MARK: - ProfileHeaderViewControllerDelegate
extension ProfileViewController: ProfileHeaderViewControllerDelegate {
func profileHeaderViewController(_ viewController: ProfileHeaderViewController, viewLayoutDidUpdate view: UIView) {
guard let scrollView = (profileSegmentedViewController.pagingViewController.currentViewController as? UserTimelineViewController)?.scrollView else {
// assertionFailure()
return
}
updateOverlayScrollViewContentSize(scrollView: scrollView)
}
func profileHeaderViewController(_ viewController: ProfileHeaderViewController, pageSegmentedControlValueChanged segmentedControl: UISegmentedControl, selectedSegmentIndex index: Int) {
profileSegmentedViewController.pagingViewController.scrollToPage(
.at(index: index),
animated: true
)
}
}
// MARK: - ProfilePagingViewControllerDelegate
extension ProfileViewController: ProfilePagingViewControllerDelegate {
func profilePagingViewController(_ viewController: ProfilePagingViewController, didScrollToPostCustomScrollViewContainerController postTimelineViewController: ScrollViewContainer, atIndex index: Int) {
os_log("%{public}s[%{public}ld], %{public}s: select at index: %ld", ((#file as NSString).lastPathComponent), #line, #function, index)
// save content offset
overlayScrollView.contentOffset.y = contentOffsets[index] ?? containerScrollView.contentOffset.y
// setup observer and gesture fallback
currentPostTimelineTableViewContentSizeObservation = observeTableViewContentSize(scrollView: postTimelineViewController.scrollView)
postTimelineViewController.scrollView.panGestureRecognizer.require(toFail: overlayScrollView.panGestureRecognizer)
// if let userMediaTimelineViewController = postTimelineViewController as? UserMediaTimelineViewController,
// let currentState = userMediaTimelineViewController.viewModel.stateMachine.currentState {
// switch currentState {
// case is UserMediaTimelineViewModel.State.NoMore,
// is UserMediaTimelineViewModel.State.NotAuthorized,
// is UserMediaTimelineViewModel.State.Blocked:
// break
// default:
// if userMediaTimelineViewController.viewModel.items.value.isEmpty {
// userMediaTimelineViewController.viewModel.stateMachine.enter(UserMediaTimelineViewModel.State.Reloading.self)
// }
// }
// }
//
// if let userLikeTimelineViewController = postTimelineViewController as? UserLikeTimelineViewController,
// let currentState = userLikeTimelineViewController.viewModel.stateMachine.currentState {
// switch currentState {
// case is UserLikeTimelineViewModel.State.NoMore,
// is UserLikeTimelineViewModel.State.NotAuthorized,
// is UserLikeTimelineViewModel.State.Blocked:
// break
// default:
// if userLikeTimelineViewController.viewModel.items.value.isEmpty {
// userLikeTimelineViewController.viewModel.stateMachine.enter(UserLikeTimelineViewModel.State.Reloading.self)
// }
// }
// }
}
}
// MARK: - ProfileBannerInfoActionViewDelegate
//extension ProfileViewController: ProfileBannerInfoActionViewDelegate {
//
// func profileBannerInfoActionView(_ profileBannerInfoActionView: ProfileBannerInfoActionView, followActionButtonPressed button: FollowActionButton) {
// UserProviderFacade
// .toggleUserFriendship(provider: self, sender: button)
// .sink { _ in
// // do nothing
// } receiveValue: { _ in
// // do nothing
// }
// .store(in: &disposeBag)
// }
//
//}
// MARK: - ProfileHeaderViewDelegate
extension ProfileViewController: ProfileHeaderViewDelegate {
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity) {
}
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) {
}
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed dwingDashboardMeterView: ProfileStatusDashboardMeterView) {
}
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed dwersDashboardMeterView: ProfileStatusDashboardMeterView) {
}
}
// MARK: - ScrollViewContainer
extension ProfileViewController: ScrollViewContainer {
var scrollView: UIScrollView { return overlayScrollView }
}

View File

@ -0,0 +1,197 @@
//
// ProfileViewModel.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-29.
//
import os.log
import UIKit
import Combine
import CoreDataStack
import MastodonSDK
// please override this base class
class ProfileViewModel: NSObject {
typealias UserID = String
var disposeBag = Set<AnyCancellable>()
var observations = Set<NSKeyValueObservation>()
private var mastodonUserObserver: AnyCancellable?
private var currentMastodonUserObserver: AnyCancellable?
// input
let context: AppContext
let mastodonUser: CurrentValueSubject<MastodonUser?, Never>
let currentMastodonUser = CurrentValueSubject<MastodonUser?, Never>(nil)
let viewDidAppear = PassthroughSubject<Void, Never>()
let headerDomainLumaStyle = CurrentValueSubject<UIUserInterfaceStyle, Never>(.dark) // default dark for placeholder banner
// output
let domain: CurrentValueSubject<String?, Never>
let userID: CurrentValueSubject<UserID?, Never>
let bannerImageURL: CurrentValueSubject<URL?, Never>
let avatarImageURL: CurrentValueSubject<URL?, Never>
// let protected: CurrentValueSubject<Bool?, Never>
let name: CurrentValueSubject<String?, Never>
let username: CurrentValueSubject<String?, Never>
let bioDescription: CurrentValueSubject<String?, Never>
let url: CurrentValueSubject<String?, Never>
let statusesCount: CurrentValueSubject<Int?, Never>
let followingCount: CurrentValueSubject<Int?, Never>
let followersCount: CurrentValueSubject<Int?, Never>
// let friendship: CurrentValueSubject<Friendship?, Never>
// let followedBy: CurrentValueSubject<Bool?, Never>
// let muted: CurrentValueSubject<Bool, Never>
// let blocked: CurrentValueSubject<Bool, Never>
//
// let suspended = CurrentValueSubject<Bool, Never>(false)
//
init(context: AppContext, optionalMastodonUser mastodonUser: MastodonUser?) {
self.context = context
self.mastodonUser = CurrentValueSubject(mastodonUser)
self.domain = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value?.domain)
self.userID = CurrentValueSubject(mastodonUser?.id)
self.bannerImageURL = CurrentValueSubject(mastodonUser?.headerImageURL())
self.avatarImageURL = CurrentValueSubject(mastodonUser?.avatarImageURL())
// self.protected = CurrentValueSubject(twitterUser?.protected)
self.name = CurrentValueSubject(mastodonUser?.displayNameWithFallback)
self.username = CurrentValueSubject(mastodonUser?.acctWithDomain)
self.bioDescription = CurrentValueSubject(mastodonUser?.note)
self.url = CurrentValueSubject(mastodonUser?.url)
self.statusesCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.statusesCount) })
self.followingCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followingCount) })
self.followersCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followersCount) })
// self.friendship = CurrentValueSubject(nil)
// self.followedBy = CurrentValueSubject(nil)
// self.muted = CurrentValueSubject(false)
// self.blocked = CurrentValueSubject(false)
super.init()
// bind active authentication
context.authenticationService.activeMastodonAuthentication
.sink { [weak self] activeMastodonAuthentication in
guard let self = self else { return }
guard let activeMastodonAuthentication = activeMastodonAuthentication else {
self.domain.value = nil
self.currentMastodonUser.value = nil
return
}
self.domain.value = activeMastodonAuthentication.domain
self.currentMastodonUser.value = activeMastodonAuthentication.user
}
.store(in: &disposeBag)
setup()
}
}
extension ProfileViewModel {
enum Friendship: CustomDebugStringConvertible {
case following
case pending
case none
var debugDescription: String {
switch self {
case .following: return "following"
case .pending: return "pending"
case .none: return "none"
}
}
}
}
extension ProfileViewModel {
private func setup() {
Publishers.CombineLatest(
mastodonUser.eraseToAnyPublisher(),
currentMastodonUser.eraseToAnyPublisher()
)
.receive(on: DispatchQueue.main)
.sink { [weak self] mastodonUser, currentMastodonUser in
guard let self = self else { return }
self.update(mastodonUser: mastodonUser)
self.update(mastodonUser: mastodonUser, currentMastodonUser: currentMastodonUser)
if let mastodonUser = mastodonUser {
// setup observer
self.mastodonUserObserver = ManagedObjectObserver.observe(object: mastodonUser)
.sink { completion in
switch completion {
case .failure(let error):
assertionFailure(error.localizedDescription)
case .finished:
assertionFailure()
}
} receiveValue: { [weak self] change in
guard let self = self else { return }
guard let changeType = change.changeType else { return }
switch changeType {
case .update:
self.update(mastodonUser: mastodonUser)
self.update(mastodonUser: mastodonUser, currentMastodonUser: currentMastodonUser)
case .delete:
// TODO:
break
}
}
} else {
self.mastodonUserObserver = nil
}
if let currentMastodonUser = currentMastodonUser {
// setup observer
self.currentMastodonUserObserver = ManagedObjectObserver.observe(object: currentMastodonUser)
.sink { completion in
switch completion {
case .failure(let error):
assertionFailure(error.localizedDescription)
case .finished:
assertionFailure()
}
} receiveValue: { [weak self] change in
guard let self = self else { return }
guard let changeType = change.changeType else { return }
switch changeType {
case .update:
self.update(mastodonUser: mastodonUser, currentMastodonUser: currentMastodonUser)
case .delete:
// TODO:
break
}
}
} else {
self.currentMastodonUserObserver = nil
}
}
.store(in: &disposeBag)
}
private func update(mastodonUser: MastodonUser?) {
self.userID.value = mastodonUser?.id
self.bannerImageURL.value = mastodonUser?.headerImageURL()
self.avatarImageURL.value = mastodonUser?.avatarImageURL()
// self.protected.value = twitterUser?.protected
self.name.value = mastodonUser?.displayNameWithFallback
self.username.value = mastodonUser?.acctWithDomain
self.bioDescription.value = mastodonUser?.note
self.url.value = mastodonUser?.url
self.statusesCount.value = mastodonUser.flatMap { Int(truncating: $0.statusesCount) }
self.followingCount.value = mastodonUser.flatMap { Int(truncating: $0.followingCount) }
self.followersCount.value = mastodonUser.flatMap { Int(truncating: $0.followersCount) }
}
private func update(mastodonUser: MastodonUser?, currentMastodonUser: MastodonUser?) {
// TODO:
}
}

View File

@ -0,0 +1,47 @@
//
// ProfilePagingViewController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-29.
//
import os.log
import UIKit
import Pageboy
import Tabman
protocol ProfilePagingViewControllerDelegate: class {
func profilePagingViewController(_ viewController: ProfilePagingViewController, didScrollToPostCustomScrollViewContainerController customScrollViewContainerController: ScrollViewContainer, atIndex index: Int)
}
final class ProfilePagingViewController: TabmanViewController {
weak var pagingDelegate: ProfilePagingViewControllerDelegate?
var viewModel: ProfilePagingViewModel!
// MARK: - PageboyViewControllerDelegate
override func pageboyViewController(_ pageboyViewController: PageboyViewController, didScrollToPageAt index: TabmanViewController.PageIndex, direction: PageboyViewController.NavigationDirection, animated: Bool) {
super.pageboyViewController(pageboyViewController, didScrollToPageAt: index, direction: direction, animated: animated)
let viewController = viewModel.viewControllers[index]
pagingDelegate?.profilePagingViewController(self, didScrollToPostCustomScrollViewContainerController: viewController, atIndex: index)
}
deinit {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension ProfilePagingViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .clear
dataSource = viewModel
}
}

View File

@ -0,0 +1,68 @@
//
// ProfilePagingViewModel.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-29.
//
import os.log
import UIKit
import Pageboy
import Tabman
final class ProfilePagingViewModel: NSObject {
let postUserTimelineViewController = UserTimelineViewController()
let repliesUserTimelineViewController = UserTimelineViewController()
let mediaUserTimelineViewController = UserTimelineViewController()
init(
postsUserTimelineViewModel: UserTimelineViewModel,
repliesUserTimelineViewModel: UserTimelineViewModel,
mediaUserTimelineViewModel: UserTimelineViewModel
) {
postUserTimelineViewController.viewModel = postsUserTimelineViewModel
repliesUserTimelineViewController.viewModel = repliesUserTimelineViewModel
mediaUserTimelineViewController.viewModel = mediaUserTimelineViewModel
super.init()
}
var viewControllers: [ScrollViewContainer] {
return [
postUserTimelineViewController,
repliesUserTimelineViewController,
mediaUserTimelineViewController,
]
}
let barItems: [TMBarItemable] = {
let items = [
TMBarItem(title: L10n.Scene.Profile.SegmentedControl.posts),
TMBarItem(title: L10n.Scene.Profile.SegmentedControl.replies),
TMBarItem(title: L10n.Scene.Profile.SegmentedControl.media),
]
return items
}()
deinit {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
// MARK: - PageboyViewControllerDataSource
extension ProfilePagingViewModel: PageboyViewControllerDataSource {
func numberOfViewControllers(in pageboyViewController: PageboyViewController) -> Int {
return viewControllers.count
}
func viewController(for pageboyViewController: PageboyViewController, at index: PageboyViewController.PageIndex) -> UIViewController? {
return viewControllers[index]
}
func defaultPage(for pageboyViewController: PageboyViewController) -> PageboyViewController.Page? {
return .first
}
}

View File

@ -0,0 +1,38 @@
//
// ProfileSegmentedViewController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-29.
//
import os.log
import UIKit
final class ProfileSegmentedViewController: UIViewController {
let pagingViewController = ProfilePagingViewController()
deinit {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension ProfileSegmentedViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .clear
addChild(pagingViewController)
pagingViewController.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(pagingViewController.view)
pagingViewController.didMove(toParent: self)
NSLayoutConstraint.activate([
pagingViewController.view.topAnchor.constraint(equalTo: view.topAnchor),
pagingViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
view.trailingAnchor.constraint(equalTo: pagingViewController.view.trailingAnchor),
view.bottomAnchor.constraint(equalTo: pagingViewController.view.bottomAnchor),
])
}
}

View File

@ -0,0 +1,87 @@
//
// UserTimelineViewController+StatusProvider.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-30.
//
import os.log
import UIKit
import Combine
import CoreData
import CoreDataStack
// MARK: - StatusProvider
extension UserTimelineViewController: StatusProvider {
func status() -> Future<Status?, Never> {
return Future { promise in promise(.success(nil)) }
}
func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future<Status?, 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
}
switch item {
case .status(let objectID, _):
let managedObjectContext = self.viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext
managedObjectContext.perform {
let status = managedObjectContext.object(with: objectID) as? Status
promise(.success(status))
}
default:
promise(.success(nil))
}
}
}
func status(for cell: UICollectionViewCell) -> Future<Status?, Never> {
return Future { promise in promise(.success(nil)) }
}
var managedObjectContext: NSManagedObjectContext {
return viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext
}
var tableViewDiffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? {
return viewModel.diffableDataSource
}
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
}
func items(indexPaths: [IndexPath]) -> [Item] {
guard let diffableDataSource = self.viewModel.diffableDataSource else {
assertionFailure()
return []
}
var items: [Item] = []
for indexPath in indexPaths {
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { continue }
items.append(item)
}
return items
}
}

View File

@ -0,0 +1,145 @@
//
// UserTimelineViewController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-29.
//
import os.log
import UIKit
import AVKit
import Combine
import CoreDataStack
import GameplayKit
// TODO: adopt MediaPreviewableViewController
final class UserTimelineViewController: UIViewController, NeedsDependency {
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
var disposeBag = Set<AnyCancellable>()
var viewModel: UserTimelineViewModel!
// let mediaPreviewTransitionController = MediaPreviewTransitionController()
lazy var tableView: UITableView = {
let tableView = UITableView()
tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self))
tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
tableView.rowHeight = UITableView.automaticDimension
tableView.separatorStyle = .none
tableView.backgroundColor = .clear
return tableView
}()
deinit {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension UserTimelineViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
tableView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(tableView)
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: view.topAnchor),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
tableView.delegate = self
viewModel.setupDiffableDataSource(
for: tableView,
dependency: self,
statusTableViewCellDelegate: self
)
// trigger user timeline loading
Publishers.CombineLatest(
viewModel.domain.removeDuplicates().eraseToAnyPublisher(),
viewModel.userID.removeDuplicates().eraseToAnyPublisher()
)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard let self = self else { return }
self.viewModel.stateMachine.enter(UserTimelineViewModel.State.Reloading.self)
}
.store(in: &disposeBag)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
tableView.deselectRow(with: transitionCoordinator, animated: animated)
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
context.videoPlaybackService.viewDidDisappear(from: self)
}
}
// MARK: - UIScrollViewDelegate
extension UserTimelineViewController {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
handleScrollViewDidScroll(scrollView)
}
}
// MARK: - UITableViewDelegate
extension UserTimelineViewController: UITableViewDelegate {
// TODO: cache cell height
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
return 200
}
}
// MARK: - AVPlayerViewControllerDelegate
extension UserTimelineViewController: AVPlayerViewControllerDelegate {
func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator)
}
func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator)
}
}
// MARK: - TimelinePostTableViewCellDelegate
extension UserTimelineViewController: StatusTableViewCellDelegate {
weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self }
func parent() -> UIViewController { return self }
}
//// MARK: - TimelineHeaderTableViewCellDelegate
//extension UserTimelineViewController: TimelineHeaderTableViewCellDelegate { }
// MARK: - CustomScrollViewContainerController
extension UserTimelineViewController: ScrollViewContainer {
var scrollView: UIScrollView { return tableView }
}
// MARK: - LoadMoreConfigurableTableViewContainer
extension UserTimelineViewController: LoadMoreConfigurableTableViewContainer {
typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell
typealias LoadingState = UserTimelineViewModel.State.LoadingMore
var loadMoreConfigurableTableView: UITableView { return tableView }
var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.stateMachine }
}

View File

@ -0,0 +1,37 @@
//
// UserTimelineViewModel+Diffable.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-30.
//
import UIKit
extension UserTimelineViewModel {
func setupDiffableDataSource(
for tableView: UITableView,
dependency: NeedsDependency,
statusTableViewCellDelegate: StatusTableViewCellDelegate
) {
let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common)
.autoconnect()
.share()
.eraseToAnyPublisher()
diffableDataSource = StatusSection.tableViewDiffableDataSource(
for: tableView,
dependency: dependency,
managedObjectContext: statusFetchedResultsController.fetchedResultsController.managedObjectContext,
timestampUpdatePublisher: timestampUpdatePublisher,
statusTableViewCellDelegate: statusTableViewCellDelegate,
timelineMiddleLoaderTableViewCellDelegate: nil
)
// set empty section to make update animation top-to-bottom style
var snapshot = NSDiffableDataSourceSnapshot<StatusSection, Item>()
snapshot.appendSections([.main])
diffableDataSource?.apply(snapshot)
}
}

View File

@ -0,0 +1,262 @@
//
// UserTimelineViewModel+State.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-30.
//
import os.log
import Foundation
import GameplayKit
import MastodonSDK
extension UserTimelineViewModel {
class State: GKState {
weak var viewModel: UserTimelineViewModel?
init(viewModel: UserTimelineViewModel) {
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 UserTimelineViewModel.State {
class Initial: UserTimelineViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
guard let viewModel = viewModel else { return false }
switch stateClass {
case is Reloading.Type:
return viewModel.userID.value != nil
case is Suspended.Type:
return true
default:
return false
}
}
}
class Reloading: UserTimelineViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Fail.Type:
return true
case is Idle.Type:
return true
case is NoMore.Type:
return true
case is NotAuthorized.Type, is Blocked.Type:
return true
case is Suspended.Type:
return true
default:
return false
}
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
// reset
viewModel.statusFetchedResultsController.statusIDs.value = []
guard let userID = viewModel.userID.value, !userID.isEmpty else {
stateMachine.enter(Fail.self)
return
}
guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else {
stateMachine.enter(Fail.self)
return
}
let domain = activeMastodonAuthenticationBox.domain
let queryFilter = viewModel.queryFilter.value
viewModel.context.apiService.userTimeline(
domain: domain,
accountID: userID,
maxID: nil,
sinceID: nil,
excludeReplies: queryFilter.excludeReplies,
excludeReblogs: queryFilter.excludeReblogs,
onlyMedia: queryFilter.onlyMedia,
authorizationBox: activeMastodonAuthenticationBox
)
.receive(on: DispatchQueue.main)
.sink { completion in
} receiveValue: { response in
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
var hasNewStatusesAppend = false
var statusIDs = viewModel.statusFetchedResultsController.statusIDs.value
for status in response.value {
guard !statusIDs.contains(status.id) else { continue }
statusIDs.append(status.id)
hasNewStatusesAppend = true
}
if hasNewStatusesAppend {
stateMachine.enter(Idle.self)
} else {
stateMachine.enter(NoMore.self)
}
viewModel.statusFetchedResultsController.statusIDs.value = statusIDs
}
.store(in: &viewModel.disposeBag)
}
}
class Fail: UserTimelineViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Reloading.Type, is LoadingMore.Type:
return true
case is Suspended.Type:
return true
default:
return false
}
}
}
class Idle: UserTimelineViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Reloading.Type, is LoadingMore.Type:
return true
case is Suspended.Type:
return true
default:
return false
}
}
}
class LoadingMore: UserTimelineViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Fail.Type:
return true
case is Idle.Type:
return true
case is NoMore.Type:
return true
case is NotAuthorized.Type, is Blocked.Type:
return true
case is Suspended.Type:
return true
default:
return false
}
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
guard let maxID = viewModel.statusFetchedResultsController.statusIDs.value.last else {
stateMachine.enter(Fail.self)
return
}
guard let userID = viewModel.userID.value, !userID.isEmpty else {
stateMachine.enter(Fail.self)
return
}
guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else {
stateMachine.enter(Fail.self)
return
}
let domain = activeMastodonAuthenticationBox.domain
let queryFilter = viewModel.queryFilter.value
viewModel.context.apiService.userTimeline(
domain: domain,
accountID: userID,
maxID: maxID,
sinceID: nil,
excludeReplies: queryFilter.excludeReplies,
excludeReblogs: queryFilter.excludeReblogs,
onlyMedia: queryFilter.onlyMedia,
authorizationBox: activeMastodonAuthenticationBox
)
.receive(on: DispatchQueue.main)
.sink { completion in
} receiveValue: { response in
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
var hasNewStatusesAppend = false
var statusIDs = viewModel.statusFetchedResultsController.statusIDs.value
for status in response.value {
guard !statusIDs.contains(status.id) else { continue }
statusIDs.append(status.id)
hasNewStatusesAppend = true
}
if hasNewStatusesAppend {
stateMachine.enter(Idle.self)
} else {
stateMachine.enter(NoMore.self)
}
viewModel.statusFetchedResultsController.statusIDs.value = statusIDs
}
.store(in: &viewModel.disposeBag)
}
}
class NotAuthorized: UserTimelineViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Reloading.Type:
return true
case is Suspended.Type:
return true
default:
return false
}
}
}
class Blocked: UserTimelineViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Reloading.Type:
return true
case is Suspended.Type:
return true
default:
return false
}
}
}
class Suspended: UserTimelineViewModel.State {
}
class NoMore: UserTimelineViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Reloading.Type:
return true
case is NotAuthorized.Type, is Blocked.Type:
return true
case is Suspended.Type:
return true
default:
return false
}
}
}
}

View File

@ -0,0 +1,127 @@
//
// UserTimelineViewModel.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-29.
//
import os.log
import UIKit
import GameplayKit
import Combine
import CoreData
import CoreDataStack
import MastodonSDK
import AlamofireImage
class UserTimelineViewModel: NSObject {
var disposeBag = Set<AnyCancellable>()
// input
let context: AppContext
let domain: CurrentValueSubject<String?, Never>
let userID: CurrentValueSubject<String?, Never>
let queryFilter: CurrentValueSubject<QueryFilter, Never>
let statusFetchedResultsController: StatusFetchedResultsController
// output
var diffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>?
private(set) lazy var stateMachine: GKStateMachine = {
let stateMachine = GKStateMachine(states: [
State.Initial(viewModel: self),
State.Reloading(viewModel: self),
State.Fail(viewModel: self),
State.Idle(viewModel: self),
State.LoadingMore(viewModel: self),
State.NotAuthorized(viewModel: self),
State.Blocked(viewModel: self),
State.Suspended(viewModel: self),
State.NoMore(viewModel: self),
])
stateMachine.enter(State.Initial.self)
return stateMachine
}()
init(context: AppContext, domain: String?, userID: String?, queryFilter: QueryFilter) {
self.context = context
self.statusFetchedResultsController = StatusFetchedResultsController(
managedObjectContext: context.managedObjectContext,
domain: domain,
additionalTweetPredicate: Status.notDeleted()
)
self.domain = CurrentValueSubject(domain)
self.userID = CurrentValueSubject(userID)
self.queryFilter = CurrentValueSubject(queryFilter)
super.init()
self.domain
.assign(to: \.value, on: statusFetchedResultsController.domain)
.store(in: &disposeBag)
statusFetchedResultsController.items
.receive(on: DispatchQueue.main)
.sink { [weak self] objectIDs in
guard let self = self else { return }
guard let diffableDataSource = self.diffableDataSource else { return }
// var isPermissionDenied = false
var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:]
let oldSnapshot = diffableDataSource.snapshot()
for item in oldSnapshot.itemIdentifiers {
guard case let .status(objectID, attribute) = item else { continue }
oldSnapshotAttributeDict[objectID] = attribute
}
var snapshot = NSDiffableDataSourceSnapshot<StatusSection, Item>()
snapshot.appendSections([.main])
var items: [Item] = []
for objectID in objectIDs {
let attribute = oldSnapshotAttributeDict[objectID] ?? Item.StatusAttribute()
items.append(.status(objectID: objectID, attribute: attribute))
}
snapshot.appendItems(items, toSection: .main)
if let currentState = self.stateMachine.currentState {
switch currentState {
case is State.Reloading, is State.LoadingMore, is State.Idle, is State.Fail:
snapshot.appendItems([.bottomLoader], toSection: .main)
// TODO: handle other states
default:
break
}
}
// not animate when empty items fix loader first appear layout issue
diffableDataSource.apply(snapshot, animatingDifferences: !items.isEmpty)
}
.store(in: &disposeBag)
}
deinit {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension UserTimelineViewModel {
struct QueryFilter {
let excludeReplies: Bool?
let excludeReblogs: Bool?
let onlyMedia: Bool?
init(
excludeReplies: Bool? = nil,
excludeReblogs: Bool? = nil,
onlyMedia: Bool? = nil
) {
self.excludeReplies = excludeReplies
self.excludeReblogs = excludeReblogs
self.onlyMedia = onlyMedia
}
}
}

View File

@ -15,11 +15,11 @@ import MastodonSDK
// MARK: - StatusProvider
extension PublicTimelineViewController: StatusProvider {
func toot() -> Future<Toot?, Never> {
func status() -> Future<Status?, Never> {
return Future { promise in promise(.success(nil)) }
}
func toot(for cell: UITableViewCell, indexPath: IndexPath?) -> Future<Toot?, Never> {
func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future<Status?, Never> {
return Future { promise in
guard let diffableDataSource = self.viewModel.diffableDataSource else {
assertionFailure()
@ -33,11 +33,11 @@ extension PublicTimelineViewController: StatusProvider {
}
switch item {
case .toot(let objectID, _):
case .status(let objectID, _):
let managedObjectContext = self.viewModel.fetchedResultsController.managedObjectContext
managedObjectContext.perform {
let toot = managedObjectContext.object(with: objectID) as? Toot
promise(.success(toot))
let status = managedObjectContext.object(with: objectID) as? Status
promise(.success(status))
}
default:
promise(.success(nil))
@ -45,7 +45,7 @@ extension PublicTimelineViewController: StatusProvider {
}
}
func toot(for cell: UICollectionViewCell) -> Future<Toot?, Never> {
func status(for cell: UICollectionViewCell) -> Future<Status?, Never> {
return Future { promise in promise(.success(nil)) }
}

View File

@ -159,13 +159,13 @@ extension PublicTimelineViewController: LoadMoreConfigurableTableViewContainer {
// MARK: - TimelineMiddleLoaderTableViewCellDelegate
extension PublicTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate {
func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineTootID: String?, timelineIndexobjectID: NSManagedObjectID?) {
guard let upperTimelineTootID = upperTimelineTootID else {return}
func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineStatusID: String?, timelineIndexobjectID: NSManagedObjectID?) {
guard let upperTimelineStatusID = upperTimelineStatusID else {return}
viewModel.loadMiddleSateMachineList
.receive(on: DispatchQueue.main)
.sink { [weak self] ids in
guard let _ = self else { return }
if let stateMachine = ids[upperTimelineTootID] {
if let stateMachine = ids[upperTimelineStatusID] {
guard let state = stateMachine.currentState else {
assertionFailure()
return
@ -185,17 +185,17 @@ extension PublicTimelineViewController: TimelineMiddleLoaderTableViewCellDelegat
.store(in: &cell.disposeBag)
var dict = viewModel.loadMiddleSateMachineList.value
if let _ = dict[upperTimelineTootID] {
if let _ = dict[upperTimelineStatusID] {
// do nothing
} else {
let stateMachine = GKStateMachine(states: [
PublicTimelineViewModel.LoadMiddleState.Initial(viewModel: viewModel, upperTimelineTootID: upperTimelineTootID),
PublicTimelineViewModel.LoadMiddleState.Loading(viewModel: viewModel, upperTimelineTootID: upperTimelineTootID),
PublicTimelineViewModel.LoadMiddleState.Fail(viewModel: viewModel, upperTimelineTootID: upperTimelineTootID),
PublicTimelineViewModel.LoadMiddleState.Success(viewModel: viewModel, upperTimelineTootID: upperTimelineTootID),
PublicTimelineViewModel.LoadMiddleState.Initial(viewModel: viewModel, upperTimelineStatusID: upperTimelineStatusID),
PublicTimelineViewModel.LoadMiddleState.Loading(viewModel: viewModel, upperTimelineStatusID: upperTimelineStatusID),
PublicTimelineViewModel.LoadMiddleState.Fail(viewModel: viewModel, upperTimelineStatusID: upperTimelineStatusID),
PublicTimelineViewModel.LoadMiddleState.Success(viewModel: viewModel, upperTimelineStatusID: upperTimelineStatusID),
])
stateMachine.enter(PublicTimelineViewModel.LoadMiddleState.Initial.self)
dict[upperTimelineTootID] = stateMachine
dict[upperTimelineStatusID] = stateMachine
viewModel.loadMiddleSateMachineList.value = dict
}
}

View File

@ -41,32 +41,32 @@ extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate {
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
os_log("%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function)
let indexes = tootIDs.value
let toots = fetchedResultsController.fetchedObjects ?? []
guard toots.count == indexes.count else { return }
let indexTootTuples: [(Int, Toot)] = toots
.compactMap { toot -> (Int, Toot)? in
guard toot.deletedAt == nil else { return nil }
return indexes.firstIndex(of: toot.id).map { index in (index, toot) }
let indexes = statusIDs.value
let statuses = fetchedResultsController.fetchedObjects ?? []
guard statuses.count == indexes.count else { return }
let indexStatusTuples: [(Int, Status)] = statuses
.compactMap { status -> (Int, Status)? in
guard status.deletedAt == nil else { return nil }
return indexes.firstIndex(of: status.id).map { index in (index, status) }
}
.sorted { $0.0 < $1.0 }
var oldSnapshotAttributeDict: [NSManagedObjectID: Item.StatusAttribute] = [:]
for item in self.items.value {
guard case let .toot(objectID, attribute) = item else { continue }
guard case let .status(objectID, attribute) = item else { continue }
oldSnapshotAttributeDict[objectID] = attribute
}
var items = [Item]()
for (_, toot) in indexTootTuples {
let targetToot = toot.reblog ?? toot
for (_, status) in indexStatusTuples {
let targetStatus = status.reblog ?? status
let isStatusTextSensitive: Bool = {
guard let spoilerText = targetToot.spoilerText, !spoilerText.isEmpty else { return false }
guard let spoilerText = targetStatus.spoilerText, !spoilerText.isEmpty else { return false }
return true
}()
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))
let attribute = oldSnapshotAttributeDict[status.objectID] ?? Item.StatusAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: targetStatus.sensitive)
items.append(Item.status(objectID: status.objectID, attribute: attribute))
if statusIDsWhichHasGap.contains(status.id) {
items.append(Item.publicMiddleLoader(statusID: status.id))
}
}

View File

@ -14,18 +14,18 @@ import os.log
extension PublicTimelineViewModel {
class LoadMiddleState: GKState {
weak var viewModel: PublicTimelineViewModel?
let upperTimelineTootID: String
let upperTimelineStatusID: String
init(viewModel: PublicTimelineViewModel, upperTimelineTootID: String) {
init(viewModel: PublicTimelineViewModel, upperTimelineStatusID: String) {
self.viewModel = viewModel
self.upperTimelineTootID = upperTimelineTootID
self.upperTimelineStatusID = upperTimelineStatusID
}
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)
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
var dict = viewModel.loadMiddleSateMachineList.value
dict[self.upperTimelineTootID] = stateMachine
dict[self.upperTimelineStatusID] = stateMachine
viewModel.loadMiddleSateMachineList.value = dict // trigger value change
}
}
@ -54,42 +54,42 @@ extension PublicTimelineViewModel.LoadMiddleState {
}
viewModel.context.apiService.publicTimeline(
domain: activeMastodonAuthenticationBox.domain,
maxID: upperTimelineTootID
maxID: upperTimelineStatusID
)
.receive(on: DispatchQueue.main)
.sink { completion in
switch completion {
case .failure(let error):
os_log("%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
os_log("%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
stateMachine.enter(Fail.self)
case .finished:
break
}
} receiveValue: { response in
let toots = response.value
let addedToots = toots.filter { !viewModel.tootIDs.value.contains($0.id) }
let statuses = response.value
let addedStatuses = statuses.filter { !viewModel.statusIDs.value.contains($0.id) }
guard let gapIndex = viewModel.tootIDs.value.firstIndex(of: self.upperTimelineTootID) else { return }
let upToots = Array(viewModel.tootIDs.value[...gapIndex])
let downToots = Array(viewModel.tootIDs.value[(gapIndex + 1)...])
guard let gapIndex = viewModel.statusIDs.value.firstIndex(of: self.upperTimelineStatusID) else { return }
let upStatuses = Array(viewModel.statusIDs.value[...gapIndex])
let downStatuses = Array(viewModel.statusIDs.value[(gapIndex + 1)...])
// construct newTootIDs
var newTootIDs = upToots
newTootIDs.append(contentsOf: addedToots.map { $0.id })
newTootIDs.append(contentsOf: downToots)
// construct newStatusIDs
var newStatusIDs = upStatuses
newStatusIDs.append(contentsOf: addedStatuses.map { $0.id })
newStatusIDs.append(contentsOf: downStatuses)
// remove old gap from viewmodel
if let index = viewModel.tootIDsWhichHasGap.firstIndex(of: self.upperTimelineTootID) {
viewModel.tootIDsWhichHasGap.remove(at: index)
if let index = viewModel.statusIDsWhichHasGap.firstIndex(of: self.upperTimelineStatusID) {
viewModel.statusIDsWhichHasGap.remove(at: index)
}
// add new gap from viewmodel if need
let intersection = toots.filter { downToots.contains($0.id) }
let intersection = statuses.filter { downStatuses.contains($0.id) }
if intersection.isEmpty {
addedToots.last.flatMap { viewModel.tootIDsWhichHasGap.append($0.id) }
addedStatuses.last.flatMap { viewModel.statusIDsWhichHasGap.append($0.id) }
}
viewModel.tootIDs.value = newTootIDs
os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld toots, %{public}%ld new toots", (#file as NSString).lastPathComponent, #line, #function, toots.count, addedToots.count)
if addedToots.isEmpty {
viewModel.statusIDs.value = newStatusIDs
os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld statuses, %{public}%ld new statues", (#file as NSString).lastPathComponent, #line, #function, statuses.count, addedStatuses.count)
if addedStatuses.isEmpty {
stateMachine.enter(Fail.self)
} else {
stateMachine.enter(Success.self)

View File

@ -68,21 +68,21 @@ extension PublicTimelineViewModel.State {
break
}
} receiveValue: { response in
let resposeTootIDs = response.value.compactMap { $0.id }
var newTootsIDs = resposeTootIDs
let oldTootsIDs = viewModel.tootIDs.value
let resposeStatusIDs = response.value.compactMap { $0.id }
var newStatusIDs = resposeStatusIDs
let oldStatusIDs = viewModel.statusIDs.value
var hasGap = true
for tootID in oldTootsIDs {
if !newTootsIDs.contains(tootID) {
newTootsIDs.append(tootID)
for statusID in oldStatusIDs {
if !newStatusIDs.contains(statusID) {
newStatusIDs.append(statusID)
} else {
hasGap = false
}
}
if hasGap && oldTootsIDs.count > 0 {
resposeTootIDs.last.flatMap { viewModel.tootIDsWhichHasGap.append($0) }
if hasGap && oldStatusIDs.count > 0 {
resposeStatusIDs.last.flatMap { viewModel.statusIDsWhichHasGap.append($0) }
}
viewModel.tootIDs.value = newTootsIDs
viewModel.statusIDs.value = newStatusIDs
stateMachine.enter(Idle.self)
}
.store(in: &viewModel.disposeBag)
@ -138,7 +138,7 @@ extension PublicTimelineViewModel.State {
stateMachine.enter(Fail.self)
return
}
let maxID = viewModel.tootIDs.value.last
let maxID = viewModel.statusIDs.value.last
viewModel.context.apiService.publicTimeline(
domain: activeMastodonAuthenticationBox.domain,
maxID: maxID
@ -153,14 +153,14 @@ extension PublicTimelineViewModel.State {
}
} receiveValue: { response in
stateMachine.enter(Idle.self)
var oldTootsIDs = viewModel.tootIDs.value
for toot in response.value {
if !oldTootsIDs.contains(toot.id) {
oldTootsIDs.append(toot.id)
var oldStatusIDs = viewModel.statusIDs.value
for status in response.value {
if !oldStatusIDs.contains(status.id) {
oldStatusIDs.append(status.id)
}
}
viewModel.tootIDs.value = oldTootsIDs
viewModel.statusIDs.value = oldStatusIDs
}
.store(in: &viewModel.disposeBag)
}

View File

@ -19,7 +19,7 @@ class PublicTimelineViewModel: NSObject {
// input
let context: AppContext
let fetchedResultsController: NSFetchedResultsController<Toot>
let fetchedResultsController: NSFetchedResultsController<Status>
let isFetchingLatestTimeline = CurrentValueSubject<Bool, Never>(false)
@ -31,7 +31,7 @@ class PublicTimelineViewModel: NSObject {
weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate?
//
var tootIDsWhichHasGap = [String]()
var statusIDsWhichHasGap = [String]()
// output
var diffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>?
@ -47,15 +47,15 @@ class PublicTimelineViewModel: NSObject {
return stateMachine
}()
let tootIDs = CurrentValueSubject<[String], Never>([])
let statusIDs = CurrentValueSubject<[String], Never>([])
let items = CurrentValueSubject<[Item], Never>([])
var cellFrameCache = NSCache<NSNumber, NSValue>()
init(context: AppContext) {
self.context = context
self.fetchedResultsController = {
let fetchRequest = Toot.sortedFetchRequest
fetchRequest.predicate = Toot.predicate(domain: "", ids: [])
let fetchRequest = Status.sortedFetchRequest
fetchRequest.predicate = Status.predicate(domain: "", ids: [])
fetchRequest.returnsObjectsAsFaults = false
fetchRequest.fetchBatchSize = 20
let controller = NSFetchedResultsController(
@ -111,12 +111,12 @@ class PublicTimelineViewModel: NSObject {
}
.store(in: &disposeBag)
tootIDs
statusIDs
.receive(on: DispatchQueue.main)
.sink { [weak self] ids in
guard let self = self else { return }
let domain = self.context.authenticationService.activeMastodonAuthenticationBox.value?.domain ?? ""
self.fetchedResultsController.fetchRequest.predicate = Toot.predicate(domain: domain, ids: ids)
self.fetchedResultsController.fetchRequest.predicate = Status.predicate(domain: domain, ids: ids)
do {
try self.fetchedResultsController.performFetch()
} catch {

View File

@ -0,0 +1,16 @@
//
// AdaptiveStatusBarStyleNavigationController.swift
//
//
// Created by MainasuK Cirno on 2021-2-26.
//
import UIKit
// Make status bar style adptive for child view controller
// SeeAlso: `modalPresentationCapturesStatusBarAppearance`
final class AdaptiveStatusBarStyleNavigationController: UINavigationController {
override var childForStatusBarStyle: UIViewController? {
return visibleViewController
}
}

View File

@ -1,14 +0,0 @@
//
// DarkContentStatusBarStyleNavigationController.swift
//
//
// Created by MainasuK Cirno on 2021-2-26.
//
import UIKit
final class DarkContentStatusBarStyleNavigationController: UINavigationController {
override var preferredStatusBarStyle: UIStatusBarStyle {
return .darkContent
}
}

View File

@ -7,7 +7,7 @@
import UIKit
final class RoundedEdgesButton: UIButton {
class RoundedEdgesButton: UIButton {
override func layoutSubviews() {
super.layoutSubviews()

View File

@ -12,6 +12,8 @@ import ActiveLabel
import AlamofireImage
protocol StatusViewDelegate: class {
func statusView(_ statusView: StatusView, headerInfoLabelDidPressed label: UILabel)
func statusView(_ statusView: StatusView, avatarButtonDidPressed button: UIButton)
func statusView(_ statusView: StatusView, contentWarningActionButtonPressed button: UIButton)
func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton)
@ -195,8 +197,9 @@ final class StatusView: UIView {
return actionToolbarContainer
}()
let activeTextLabel = ActiveLabel(style: .default)
private let headerInfoLabelTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer
override init(frame: CGRect) {
super.init(frame: frame)
@ -226,7 +229,7 @@ final class StatusView: UIView {
extension StatusView {
func _init() {
// container: [retoot | author | status | action toolbar]
// container: [reblog | author | status | action toolbar]
let containerStackView = UIStackView()
containerStackView.axis = .vertical
containerStackView.spacing = 10
@ -401,6 +404,12 @@ extension StatusView {
playerContainerView.delegate = self
headerInfoLabelTapGestureRecognizer.addTarget(self, action: #selector(StatusView.headerInfoLabelTapGestureRecognizerHandler(_:)))
headerInfoLabel.isUserInteractionEnabled = true
headerInfoLabel.addGestureRecognizer(headerInfoLabelTapGestureRecognizer)
avatarButton.addTarget(self, action: #selector(StatusView.avatarButtonDidPressed(_:)), for: .touchUpInside)
avatarStackedContainerButton.addTarget(self, action: #selector(StatusView.avatarStackedContainerButtonDidPressed(_:)), for: .touchUpInside)
contentWarningActionButton.addTarget(self, action: #selector(StatusView.contentWarningActionButtonPressed(_:)), for: .touchUpInside)
pollVoteButton.addTarget(self, action: #selector(StatusView.pollVoteButtonPressed(_:)), for: .touchUpInside)
}
@ -439,6 +448,21 @@ extension StatusView {
extension StatusView {
@objc private func headerInfoLabelTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
delegate?.statusView(self, headerInfoLabelDidPressed: headerInfoLabel)
}
@objc private func avatarButtonDidPressed(_ sender: UIButton) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
delegate?.statusView(self, avatarButtonDidPressed: sender)
}
@objc private func avatarStackedContainerButtonDidPressed(_ sender: UIButton) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
delegate?.statusView(self, avatarButtonDidPressed: sender)
}
@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)

View File

@ -20,6 +20,8 @@ protocol StatusTableViewCellDelegate: class {
var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { get }
func statusTableViewCell(_ cell: StatusTableViewCell, playerViewControllerDidPressed playerViewController: AVPlayerViewController)
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, headerInfoLabelDidPressed label: UILabel)
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, avatarButtonDidPressed button: UIButton)
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton)
func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int)
@ -194,6 +196,14 @@ extension StatusTableViewCell: UITableViewDelegate {
// MARK: - StatusViewDelegate
extension StatusTableViewCell: StatusViewDelegate {
func statusView(_ statusView: StatusView, headerInfoLabelDidPressed label: UILabel) {
delegate?.statusTableViewCell(self, statusView: statusView, headerInfoLabelDidPressed: label)
}
func statusView(_ statusView: StatusView, avatarButtonDidPressed button: UIButton) {
delegate?.statusTableViewCell(self, statusView: statusView, avatarButtonDidPressed: button)
}
func statusView(_ statusView: StatusView, contentWarningActionButtonPressed button: UIButton) {
delegate?.statusTableViewCell(self, statusView: statusView, contentWarningActionButtonPressed: button)
}

View File

@ -11,7 +11,7 @@ import os.log
import UIKit
protocol TimelineMiddleLoaderTableViewCellDelegate: class {
func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineTootID: String?, timelineIndexobjectID:NSManagedObjectID?)
func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineStatusID: String?, timelineIndexobjectID:NSManagedObjectID?)
func timelineMiddleLoaderTableViewCell(_ cell: TimelineMiddleLoaderTableViewCell, loadMoreButtonDidPressed button: UIButton)
}

View File

@ -17,29 +17,29 @@ extension APIService {
// make local state change only
func like(
tootObjectID: NSManagedObjectID,
statusObjectID: NSManagedObjectID,
mastodonUserObjectID: NSManagedObjectID,
favoriteKind: Mastodon.API.Favorites.FavoriteKind
) -> AnyPublisher<Toot.ID, Error> {
var _targetTootID: Toot.ID?
) -> AnyPublisher<Status.ID, Error> {
var _targetStatusID: Status.ID?
let managedObjectContext = backgroundManagedObjectContext
return managedObjectContext.performChanges {
let toot = managedObjectContext.object(with: tootObjectID) as! Toot
let status = managedObjectContext.object(with: statusObjectID) as! Status
let mastodonUser = managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser
let targetToot = toot.reblog ?? toot
let targetTootID = targetToot.id
_targetTootID = targetTootID
let targetStatus = status.reblog ?? status
let targetStatusID = targetStatus.id
_targetStatusID = targetStatusID
targetToot.update(liked: favoriteKind == .create, mastodonUser: mastodonUser)
targetStatus.update(liked: favoriteKind == .create, by: mastodonUser)
}
.tryMap { result in
switch result {
case .success:
guard let targetTootID = _targetTootID else {
guard let targetStatusID = _targetStatusID else {
throw APIError.implicit(.badRequest)
}
return targetTootID
return targetStatusID
case .failure(let error):
assertionFailure(error.localizedDescription)
@ -76,12 +76,12 @@ extension APIService {
return nil
}
}()
let _oldToot: Toot? = {
let request = Toot.sortedFetchRequest
request.predicate = Toot.predicate(domain: mastodonAuthenticationBox.domain, id: statusID)
let _oldStatus: Status? = {
let request = Status.sortedFetchRequest
request.predicate = Status.predicate(domain: mastodonAuthenticationBox.domain, id: statusID)
request.fetchLimit = 1
request.returnsObjectsAsFaults = false
request.relationshipKeyPathsForPrefetching = [#keyPath(Toot.reblog)]
request.relationshipKeyPathsForPrefetching = [#keyPath(Status.reblog)]
do {
return try managedObjectContext.fetch(request).first
} catch {
@ -91,15 +91,15 @@ extension APIService {
}()
guard let requestMastodonUser = _requestMastodonUser,
let oldToot = _oldToot else {
let oldStatus = _oldStatus else {
assertionFailure()
return
}
APIService.CoreData.merge(toot: oldToot, entity: entity, requestMastodonUser: requestMastodonUser, domain: mastodonAuthenticationBox.domain, networkDate: response.networkDate)
APIService.CoreData.merge(status: oldStatus, entity: entity, requestMastodonUser: requestMastodonUser, domain: mastodonAuthenticationBox.domain, networkDate: response.networkDate)
if favoriteKind == .destroy {
oldToot.update(favouritesCount: NSNumber(value: max(0, oldToot.favouritesCount.intValue - 1)))
oldStatus.update(favouritesCount: NSNumber(value: max(0, oldStatus.favouritesCount.intValue - 1)))
}
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: did update toot %{public}s like status to: %{public}s. now %ld likes", ((#file as NSString).lastPathComponent), #line, #function, entity.id, entity.favourited.flatMap { $0 ? "like" : "unlike" } ?? "<nil>", entity.favouritesCount )
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: did update status %{public}s like status to: %{public}s. now %ld likes", ((#file as NSString).lastPathComponent), #line, #function, entity.id, entity.favourited.flatMap { $0 ? "like" : "unlike" } ?? "<nil>", entity.favouritesCount )
}
.setFailureType(to: Error.self)
.tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.Status> in
@ -129,7 +129,7 @@ extension APIService {
extension APIService {
func likeList(
limit: Int = onceRequestTootMaxCount,
limit: Int = onceRequestStatusMaxCount,
userID: String,
maxID: String? = nil,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox

View File

@ -19,7 +19,7 @@ extension APIService {
domain: String,
sinceID: Mastodon.Entity.Status.ID? = nil,
maxID: Mastodon.Entity.Status.ID? = nil,
limit: Int = onceRequestTootMaxCount,
limit: Int = onceRequestStatusMaxCount,
local: Bool? = nil,
authorizationBox: AuthenticationService.MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> {

View File

@ -21,7 +21,7 @@ extension APIService {
domain: String,
sinceID: Mastodon.Entity.Status.ID? = nil,
maxID: Mastodon.Entity.Status.ID? = nil,
limit: Int = onceRequestTootMaxCount
limit: Int = onceRequestStatusMaxCount
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> {
let query = Mastodon.API.Timeline.PublicTimelineQuery(
local: nil,

View File

@ -16,34 +16,34 @@ extension APIService {
// make local state change only
func reblog(
tootObjectID: NSManagedObjectID,
statusObjectID: NSManagedObjectID,
mastodonUserObjectID: NSManagedObjectID,
reblogKind: Mastodon.API.Reblog.ReblogKind
) -> AnyPublisher<Toot.ID, Error> {
var _targetTootID: Toot.ID?
) -> AnyPublisher<Status.ID, Error> {
var _targetStatusID: Status.ID?
let managedObjectContext = backgroundManagedObjectContext
return managedObjectContext.performChanges {
let toot = managedObjectContext.object(with: tootObjectID) as! Toot
let status = managedObjectContext.object(with: statusObjectID) as! Status
let mastodonUser = managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser
let targetToot = toot.reblog ?? toot
let targetTootID = targetToot.id
_targetTootID = targetTootID
let targetStatus = status.reblog ?? status
let targetStatusID = targetStatus.id
_targetStatusID = targetStatusID
switch reblogKind {
case .reblog:
targetToot.update(reblogged: true, mastodonUser: mastodonUser)
targetStatus.update(reblogged: true, by: mastodonUser)
case .undoReblog:
targetToot.update(reblogged: false, mastodonUser: mastodonUser)
targetStatus.update(reblogged: false, by: mastodonUser)
}
}
.tryMap { result in
switch result {
case .success:
guard let targetTootID = _targetTootID else {
guard let targetStatusID = _targetStatusID else {
throw APIError.implicit(.badRequest)
}
return targetTootID
return targetStatusID
case .failure(let error):
assertionFailure(error.localizedDescription)
@ -85,25 +85,25 @@ extension APIService {
return
}
guard let oldToot: Toot = {
let request = Toot.sortedFetchRequest
request.predicate = Toot.predicate(domain: domain, id: statusID)
guard let oldStatus: Status = {
let request = Status.sortedFetchRequest
request.predicate = Status.predicate(domain: domain, id: statusID)
request.fetchLimit = 1
request.returnsObjectsAsFaults = false
request.relationshipKeyPathsForPrefetching = [#keyPath(Toot.reblog)]
request.relationshipKeyPathsForPrefetching = [#keyPath(Status.reblog)]
return managedObjectContext.safeFetch(request).first
}() else {
return
}
APIService.CoreData.merge(toot: oldToot, entity: entity.reblog ?? entity, requestMastodonUser: requestMastodonUser, domain: mastodonAuthenticationBox.domain, networkDate: response.networkDate)
APIService.CoreData.merge(status: oldStatus, entity: entity.reblog ?? entity, requestMastodonUser: requestMastodonUser, domain: mastodonAuthenticationBox.domain, networkDate: response.networkDate)
switch reblogKind {
case .undoReblog:
oldToot.update(reblogsCount: NSNumber(value: max(0, oldToot.reblogsCount.intValue - 1)))
oldStatus.update(reblogsCount: NSNumber(value: max(0, oldStatus.reblogsCount.intValue - 1)))
default:
break
}
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: did update toot %{public}s reblog status to: %{public}s. now %ld reblog", ((#file as NSString).lastPathComponent), #line, #function, entity.id, entity.reblogged.flatMap { $0 ? "reblog" : "unreblog" } ?? "<nil>", entity.reblogsCount )
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: did update status %{public}s reblog status to: %{public}s. now %ld reblog", ((#file as NSString).lastPathComponent), #line, #function, entity.id, entity.reblogged.flatMap { $0 ? "reblog" : "unreblog" } ?? "<nil>", entity.reblogsCount )
}
.setFailureType(to: Error.self)
.tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.Status> in

View File

@ -0,0 +1,65 @@
//
// APIService+Relationship.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-1.
//
import Foundation
import Combine
import CoreData
import CoreDataStack
import CommonOSLog
import MastodonSDK
extension APIService {
func relationship(
domain: String,
accountIDs: [Mastodon.Entity.Account.ID],
authorizationBox: AuthenticationService.MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Relationship]>, Error> {
fatalError()
// let authorization = authorizationBox.userAuthorization
// let requestMastodonUserID = authorizationBox.userID
// let query = Mastodon.API.Account.AccountStatuseseQuery(
// maxID: maxID,
// sinceID: sinceID,
// excludeReplies: excludeReplies,
// excludeReblogs: excludeReblogs,
// onlyMedia: onlyMedia,
// limit: limit
// )
//
// return Mastodon.API.Account.statuses(
// session: session,
// domain: domain,
// accountID: accountID,
// query: query,
// authorization: authorization
// )
// .flatMap { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> in
// return APIService.Persist.persistStatus(
// managedObjectContext: self.backgroundManagedObjectContext,
// domain: domain,
// query: nil,
// response: response,
// persistType: .user,
// requestMastodonUserID: requestMastodonUserID,
// log: OSLog.api
// )
// .setFailureType(to: Error.self)
// .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Status]> in
// switch result {
// case .success:
// return response
// case .failure(let error):
// throw error
// }
// }
// .eraseToAnyPublisher()
// }
// .eraseToAnyPublisher()
}
}

View File

@ -0,0 +1,70 @@
//
// APIService+UserTimeline.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-30.
//
import Foundation
import Combine
import CoreData
import CoreDataStack
import CommonOSLog
import MastodonSDK
extension APIService {
func userTimeline(
domain: String,
accountID: String,
maxID: Mastodon.Entity.Status.ID? = nil,
sinceID: Mastodon.Entity.Status.ID? = nil,
limit: Int = onceRequestStatusMaxCount,
excludeReplies: Bool? = nil,
excludeReblogs: Bool? = nil,
onlyMedia: Bool? = nil,
authorizationBox: AuthenticationService.MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> {
let authorization = authorizationBox.userAuthorization
let requestMastodonUserID = authorizationBox.userID
let query = Mastodon.API.Account.AccountStatuseseQuery(
maxID: maxID,
sinceID: sinceID,
excludeReplies: excludeReplies,
excludeReblogs: excludeReblogs,
onlyMedia: onlyMedia,
limit: limit
)
return Mastodon.API.Account.statuses(
session: session,
domain: domain,
accountID: accountID,
query: query,
authorization: authorization
)
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> in
return APIService.Persist.persistStatus(
managedObjectContext: self.backgroundManagedObjectContext,
domain: domain,
query: nil,
response: response,
persistType: .user,
requestMastodonUserID: requestMastodonUserID,
log: OSLog.api
)
.setFailureType(to: Error.self)
.tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Status]> in
switch result {
case .success:
return response
case .failure(let error):
throw error
}
}
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
}

View File

@ -44,7 +44,7 @@ final class APIService {
}
extension APIService {
public static let onceRequestTootMaxCount = 100
public static let onceRequestStatusMaxCount = 100
public static let onceRequestUserMaxCount = 100
}

View File

@ -48,11 +48,11 @@ extension APIService.CoreData {
if let oldMastodonUser = oldMastodonUser {
// merge old mastodon usre
APIService.CoreData.mergeMastodonUser(
for: requestMastodonUser,
old: oldMastodonUser,
in: domain,
APIService.CoreData.merge(
user: oldMastodonUser,
entity: entity,
requestMastodonUser: requestMastodonUser,
domain: domain,
networkDate: networkDate
)
return (oldMastodonUser, false)
@ -68,11 +68,15 @@ extension APIService.CoreData {
}
}
static func mergeMastodonUser(
for requestMastodonUser: MastodonUser?,
old user: MastodonUser,
in domain: String,
}
extension APIService.CoreData {
static func merge(
user: MastodonUser,
entity: Mastodon.Entity.Account,
requestMastodonUser: MastodonUser?,
domain: String,
networkDate: Date
) {
guard networkDate > user.updatedAt else { return }
@ -84,6 +88,38 @@ extension APIService.CoreData {
user.update(displayName: property.displayName)
user.update(avatar: property.avatar)
user.update(avatarStatic: property.avatarStatic)
user.update(header: property.header)
user.update(headerStatic: property.headerStatic)
user.update(note: property.note)
user.update(url: property.url)
user.update(statusesCount: property.statusesCount)
user.update(followingCount: property.followingCount)
user.update(followersCount: property.followersCount)
user.didUpdate(at: networkDate)
}
}
extension APIService.CoreData {
static func update(
user: MastodonUser,
entity: Mastodon.Entity.Relationship,
requestMastodonUser: MastodonUser,
domain: String,
networkDate: Date
) {
guard networkDate > user.updatedAt else { return }
user.update(isFollowing: entity.following, by: requestMastodonUser)
entity.requested.flatMap { user.update(isFollowRequested: $0, by: requestMastodonUser) }
entity.endorsed.flatMap { user.update(isEndorsed: $0, by: requestMastodonUser) }
requestMastodonUser.update(isFollowing: entity.followedBy, by: user)
entity.muting.flatMap { user.update(isMuting: $0, by: requestMastodonUser) }
user.update(isBlocking: entity.blocking, by: requestMastodonUser)
entity.domainBlocking.flatMap { user.update(isDomainBlocking: $0, by: requestMastodonUser) }
entity.blockedBy.flatMap { requestMastodonUser.update(isBlocking: $0, by: user) }
user.didUpdate(at: networkDate)
}

View File

@ -18,39 +18,39 @@ extension APIService.CoreData {
for requestMastodonUser: MastodonUser?,
domain: String,
entity: Mastodon.Entity.Status,
tootCache: APIService.Persist.PersistCache<Toot>?,
statusCache: APIService.Persist.PersistCache<Status>?,
userCache: APIService.Persist.PersistCache<MastodonUser>?,
networkDate: Date,
log: OSLog
) -> (Toot: Toot, isTootCreated: Bool, isMastodonUserCreated: Bool) {
) -> (status: Status, isStatusCreated: Bool, isMastodonUserCreated: Bool) {
let processEntityTaskSignpostID = OSSignpostID(log: log)
os_signpost(.begin, log: log, name: "update database - process entity: createOrMergeToot", signpostID: processEntityTaskSignpostID, "process toot %{public}s", entity.id)
os_signpost(.begin, log: log, name: "update database - process entity: createOrMergeStatus", signpostID: processEntityTaskSignpostID, "process status %{public}s", entity.id)
defer {
os_signpost(.end, log: log, name: "update database - process entity: createOrMergeToot", signpostID: processEntityTaskSignpostID, "process toot %{public}s", entity.id)
os_signpost(.end, log: log, name: "update database - process entity: createOrMergeStatus", signpostID: processEntityTaskSignpostID, "process status %{public}s", entity.id)
}
// build tree
let reblog = entity.reblog.flatMap { entity -> Toot in
let (toot, _, _) = createOrMergeStatus(
let reblog = entity.reblog.flatMap { entity -> Status in
let (status, _, _) = createOrMergeStatus(
into: managedObjectContext,
for: requestMastodonUser,
domain: domain,
entity: entity,
tootCache: tootCache,
statusCache: statusCache,
userCache: userCache,
networkDate: networkDate,
log: log
)
return toot
return status
}
// fetch old Toot
let oldToot: Toot? = {
if let tootCache = tootCache {
return tootCache.dictionary[entity.id]
// fetch old Status
let oldStatus: Status? = {
if let statusCache = statusCache {
return statusCache.dictionary[entity.id]
} else {
let request = Toot.sortedFetchRequest
request.predicate = Toot.predicate(domain: domain, id: entity.id)
let request = Status.sortedFetchRequest
request.predicate = Status.predicate(domain: domain, id: entity.id)
request.fetchLimit = 1
request.returnsObjectsAsFaults = false
do {
@ -62,19 +62,19 @@ extension APIService.CoreData {
}
}()
if let oldToot = oldToot {
// merge old Toot
APIService.CoreData.merge(toot: oldToot, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: networkDate)
return (oldToot, false, false)
if let oldStatus = oldStatus {
// merge old Status
APIService.CoreData.merge(status: oldStatus, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: networkDate)
return (oldStatus, false, false)
} else {
let (mastodonUser, isMastodonUserCreated) = createOrMergeMastodonUser(into: managedObjectContext, for: requestMastodonUser,in: domain, entity: entity.account, userCache: userCache, networkDate: networkDate, log: log)
let application = entity.application.flatMap { app -> Application? in
Application.insert(into: managedObjectContext, property: Application.Property(name: app.name, website: app.website, vapidKey: app.vapidKey))
}
let replyTo: Toot? = {
// could be nil if target replyTo toot's persist task in the queue
let replyTo: Status? = {
// could be nil if target replyTo status's persist task in the queue
guard let inReplyToID = entity.inReplyToID,
let replyTo = tootCache?.dictionary[inReplyToID] else { return nil }
let replyTo = statusCache?.dictionary[inReplyToID] else { return nil }
return replyTo
}()
let poll = entity.poll.flatMap { poll -> Poll in
@ -111,10 +111,10 @@ extension APIService.CoreData {
guard !attachments.isEmpty else { return nil }
return attachments
}()
let tootProperty = Toot.Property(entity: entity, domain: domain, networkDate: networkDate)
let toot = Toot.insert(
let statusProperty = Status.Property(entity: entity, domain: domain, networkDate: networkDate)
let status = Status.insert(
into: managedObjectContext,
property: tootProperty,
property: statusProperty,
author: mastodonUser,
reblog: reblog,
application: application,
@ -130,67 +130,81 @@ extension APIService.CoreData {
bookmarkedBy: (entity.bookmarked ?? false) ? requestMastodonUser : nil,
pinnedBy: (entity.pinned ?? false) ? requestMastodonUser : nil
)
tootCache?.dictionary[entity.id] = toot
os_signpost(.event, log: log, name: "update database - process entity: createOrMergeToot", signpostID: processEntityTaskSignpostID, "did insert new tweet %{public}s: %s", mastodonUser.identifier, entity.id)
return (toot, true, isMastodonUserCreated)
statusCache?.dictionary[entity.id] = status
os_signpost(.event, log: log, name: "update database - process entity: createOrMergeStatus", signpostID: processEntityTaskSignpostID, "did insert new tweet %{public}s: %s", mastodonUser.identifier, entity.id)
return (status, true, isMastodonUserCreated)
}
}
}
extension APIService.CoreData {
static func merge(
toot: Toot,
status: Status,
entity: Mastodon.Entity.Status,
requestMastodonUser: MastodonUser?,
domain: String,
networkDate: Date
) {
guard networkDate > toot.updatedAt else { return }
guard networkDate > status.updatedAt else { return }
// merge poll
if let poll = toot.poll, let entity = entity.poll {
if let poll = status.poll, let entity = entity.poll {
merge(poll: poll, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: networkDate)
}
// merge metrics
if entity.favouritesCount != toot.favouritesCount.intValue {
toot.update(favouritesCount:NSNumber(value: entity.favouritesCount))
if entity.favouritesCount != status.favouritesCount.intValue {
status.update(favouritesCount:NSNumber(value: entity.favouritesCount))
}
if let repliesCount = entity.repliesCount {
if (repliesCount != toot.repliesCount?.intValue) {
toot.update(repliesCount:NSNumber(value: repliesCount))
if (repliesCount != status.repliesCount?.intValue) {
status.update(repliesCount:NSNumber(value: repliesCount))
}
}
if entity.reblogsCount != toot.reblogsCount.intValue {
toot.update(reblogsCount:NSNumber(value: entity.reblogsCount))
if entity.reblogsCount != status.reblogsCount.intValue {
status.update(reblogsCount:NSNumber(value: entity.reblogsCount))
}
// merge relationship
if let mastodonUser = requestMastodonUser {
if let favourited = entity.favourited {
toot.update(liked: favourited, mastodonUser: mastodonUser)
status.update(liked: favourited, by: mastodonUser)
}
if let reblogged = entity.reblogged {
toot.update(reblogged: reblogged, mastodonUser: mastodonUser)
status.update(reblogged: reblogged, by: mastodonUser)
}
if let muted = entity.muted {
toot.update(muted: muted, mastodonUser: mastodonUser)
status.update(muted: muted, by: mastodonUser)
}
if let bookmarked = entity.bookmarked {
toot.update(bookmarked: bookmarked, mastodonUser: mastodonUser)
status.update(bookmarked: bookmarked, by: mastodonUser)
}
}
// set updateAt
toot.didUpdate(at: networkDate)
status.didUpdate(at: networkDate)
// merge user
mergeMastodonUser(for: requestMastodonUser, old: toot.author, in: domain, entity: entity.account, networkDate: networkDate)
merge(
user: status.author,
entity: entity.account,
requestMastodonUser: requestMastodonUser,
domain: domain,
networkDate: networkDate
)
// merge indirect reblog
if let reblog = toot.reblog, let reblogEntity = entity.reblog {
merge(toot: reblog, entity: reblogEntity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: networkDate)
if let reblog = status.reblog, let reblogEntity = entity.reblog {
merge(
status: reblog,
entity: reblogEntity,
requestMastodonUser: requestMastodonUser,
domain: domain,
networkDate: networkDate
)
}
}
}
extension APIService.CoreData {

View File

@ -17,23 +17,23 @@ extension APIService.Persist {
}
extension APIService.Persist.PersistCache where T == Toot {
extension APIService.Persist.PersistCache where T == Status {
static func ids(for toots: [Mastodon.Entity.Status]) -> Set<Mastodon.Entity.Status.ID> {
static func ids(for statuses: [Mastodon.Entity.Status]) -> Set<Mastodon.Entity.Status.ID> {
var value = Set<String>()
for toot in toots {
value = value.union(ids(for: toot))
for status in statuses {
value = value.union(ids(for: status))
}
return value
}
static func ids(for toot: Mastodon.Entity.Status) -> Set<Mastodon.Entity.Status.ID> {
static func ids(for status: Mastodon.Entity.Status) -> Set<Mastodon.Entity.Status.ID> {
var value = Set<String>()
value.insert(toot.id)
if let inReplyToID = toot.inReplyToID {
value.insert(status.id)
if let inReplyToID = status.inReplyToID {
value.insert(inReplyToID)
}
if let reblog = toot.reblog {
if let reblog = status.reblog {
value = value.union(ids(for: reblog))
}
return value
@ -43,21 +43,21 @@ extension APIService.Persist.PersistCache where T == Toot {
extension APIService.Persist.PersistCache where T == MastodonUser {
static func ids(for toots: [Mastodon.Entity.Status]) -> Set<Mastodon.Entity.Account.ID> {
static func ids(for statuses: [Mastodon.Entity.Status]) -> Set<Mastodon.Entity.Account.ID> {
var value = Set<String>()
for toot in toots {
value = value.union(ids(for: toot))
for status in statuses {
value = value.union(ids(for: status))
}
return value
}
static func ids(for toot: Mastodon.Entity.Status) -> Set<Mastodon.Entity.Account.ID> {
static func ids(for status: Mastodon.Entity.Status) -> Set<Mastodon.Entity.Account.ID> {
var value = Set<String>()
value.insert(toot.account.id)
if let inReplyToAccountID = toot.inReplyToAccountID {
value.insert(status.account.id)
if let inReplyToAccountID = status.inReplyToAccountID {
value.insert(inReplyToAccountID)
}
if let reblog = toot.reblog {
if let reblog = status.reblog {
value = value.union(ids(for: reblog))
}
return value

View File

@ -125,36 +125,36 @@ extension APIService.Persist.PersistMemo {
}
extension APIService.Persist.PersistMemo where T == Toot, U == MastodonUser {
extension APIService.Persist.PersistMemo where T == Status, U == MastodonUser {
static func createOrMergeToot(
static func createOrMergeStatus(
into managedObjectContext: NSManagedObjectContext,
for requestMastodonUser: MastodonUser?,
requestMastodonUserID: MastodonUser.ID?,
domain: String,
entity: Mastodon.Entity.Status,
memoType: MemoType,
tootCache: APIService.Persist.PersistCache<T>?,
statusCache: APIService.Persist.PersistCache<T>?,
userCache: APIService.Persist.PersistCache<U>?,
networkDate: Date,
log: OSLog
) -> APIService.Persist.PersistMemo<T, U> {
let processEntityTaskSignpostID = OSSignpostID(log: log)
os_signpost(.begin, log: log, name: "update database - process entity: createOrMergeToot", signpostID: processEntityTaskSignpostID, "process toot %{public}s", entity.id)
os_signpost(.begin, log: log, name: "update database - process entity: createOrMergeStatus", signpostID: processEntityTaskSignpostID, "process status %{public}s", entity.id)
defer {
os_signpost(.end, log: log, name: "update database - process entity: createOrMergeToot", signpostID: processEntityTaskSignpostID, "finish process toot %{public}s", entity.id)
os_signpost(.end, log: log, name: "update database - process entity: createOrMergeStatus", signpostID: processEntityTaskSignpostID, "finish process status %{public}s", entity.id)
}
// build tree
let reblogMemo = entity.reblog.flatMap { entity -> APIService.Persist.PersistMemo<T, U> in
createOrMergeToot(
createOrMergeStatus(
into: managedObjectContext,
for: requestMastodonUser,
requestMastodonUserID: requestMastodonUserID,
domain: domain,
entity: entity,
memoType: .reblog,
tootCache: tootCache,
statusCache: statusCache,
userCache: userCache,
networkDate: networkDate,
log: log
@ -163,27 +163,27 @@ extension APIService.Persist.PersistMemo where T == Toot, U == MastodonUser {
let children = [reblogMemo].compactMap { $0 }
let (toot, isTootCreated, isMastodonUserCreated) = APIService.CoreData.createOrMergeStatus(
let (status, isStatusCreated, isMastodonUserCreated) = APIService.CoreData.createOrMergeStatus(
into: managedObjectContext,
for: requestMastodonUser,
domain: domain,
entity: entity,
tootCache: tootCache,
statusCache: statusCache,
userCache: userCache,
networkDate: networkDate,
log: log
)
let memo = APIService.Persist.PersistMemo<T, U>(
status: toot,
status: status,
children: children,
memoType: memoType,
statusProcessType: isTootCreated ? .create : .merge,
statusProcessType: isStatusCreated ? .create : .merge,
authorProcessType: isMastodonUserCreated ? .create : .merge
)
switch (memo.statusProcessType, memoType) {
case (.create, .homeTimeline), (.merge, .homeTimeline):
let timelineIndex = toot.homeTimelineIndexes?
let timelineIndex = status.homeTimelineIndexes?
.first { $0.userID == requestMastodonUserID }
guard let requestMastodonUserID = requestMastodonUserID else {
assertionFailure()
@ -192,7 +192,7 @@ extension APIService.Persist.PersistMemo where T == Toot, U == MastodonUser {
if timelineIndex == nil {
// make it indexed
let timelineIndexProperty = HomeTimelineIndex.Property(domain: domain, userID: requestMastodonUserID)
let _ = HomeTimelineIndex.insert(into: managedObjectContext, property: timelineIndexProperty, toot: toot)
let _ = HomeTimelineIndex.insert(into: managedObjectContext, property: timelineIndexProperty, status: status)
} else {
// enity already in home timeline
}

View File

@ -18,6 +18,7 @@ extension APIService.Persist {
enum PersistTimelineType {
case `public`
case home
case user
case likeList
case lookUp
}
@ -32,8 +33,8 @@ extension APIService.Persist {
log: OSLog
) -> AnyPublisher<Result<Void, Error>, Never> {
return managedObjectContext.performChanges {
let toots = response.value
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: persist %{public}ld toots…", ((#file as NSString).lastPathComponent), #line, #function, toots.count)
let statuses = response.value
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: persist %{public}ld statuses…", ((#file as NSString).lastPathComponent), #line, #function, statuses.count)
let contextTaskSignpostID = OSSignpostID(log: log)
let start = CACurrentMediaTime()
@ -61,18 +62,18 @@ extension APIService.Persist {
// load working set into context to avoid cache miss
let cacheTaskSignpostID = OSSignpostID(log: log)
os_signpost(.begin, log: log, name: "load toots & users into cache", signpostID: cacheTaskSignpostID)
os_signpost(.begin, log: log, name: "load statuses & users into cache", signpostID: cacheTaskSignpostID)
// contains reblog
let tootCache: PersistCache<Toot> = {
let cache = PersistCache<Toot>()
let cacheIDs = PersistCache<Toot>.ids(for: toots)
let cachedToots: [Toot] = {
let request = Toot.sortedFetchRequest
let statusCache: PersistCache<Status> = {
let cache = PersistCache<Status>()
let cacheIDs = PersistCache<Status>.ids(for: statuses)
let cachedStatuses: [Status] = {
let request = Status.sortedFetchRequest
let ids = Array(cacheIDs)
request.predicate = Toot.predicate(domain: domain, ids: ids)
request.predicate = Status.predicate(domain: domain, ids: ids)
request.returnsObjectsAsFaults = false
request.relationshipKeyPathsForPrefetching = [#keyPath(Toot.reblog)]
request.relationshipKeyPathsForPrefetching = [#keyPath(Status.reblog)]
do {
return try managedObjectContext.fetch(request)
} catch {
@ -80,16 +81,16 @@ extension APIService.Persist {
return []
}
}()
for toot in cachedToots {
cache.dictionary[toot.id] = toot
for status in cachedStatuses {
cache.dictionary[status.id] = status
}
os_signpost(.event, log: log, name: "load toot into cache", signpostID: cacheTaskSignpostID, "cached %{public}ld toots", cachedToots.count)
os_signpost(.event, log: log, name: "load status into cache", signpostID: cacheTaskSignpostID, "cached %{public}ld statuses", cachedStatuses.count)
return cache
}()
let userCache: PersistCache<MastodonUser> = {
let cache = PersistCache<MastodonUser>()
let cacheIDs = PersistCache<MastodonUser>.ids(for: toots)
let cacheIDs = PersistCache<MastodonUser>.ids(for: statuses)
let cachedMastodonUsers: [MastodonUser] = {
let request = MastodonUser.sortedFetchRequest
let ids = Array(cacheIDs)
@ -109,40 +110,41 @@ extension APIService.Persist {
return cache
}()
os_signpost(.end, log: log, name: "load toots & users into cache", signpostID: cacheTaskSignpostID)
os_signpost(.end, log: log, name: "load statuses & users into cache", signpostID: cacheTaskSignpostID)
// remote timeline merge local timeline record set
// declare it before persist
let mergedOldTootsInTimeline = tootCache.dictionary.values.filter {
let mergedOldStatusesInTimeline = statusCache.dictionary.values.filter {
return $0.homeTimelineIndexes?.contains(where: { $0.userID == requestMastodonUserID }) ?? false
}
let updateDatabaseTaskSignpostID = OSSignpostID(log: log)
let memoType: PersistMemo<Toot, MastodonUser>.MemoType = {
let memoType: PersistMemo<Status, MastodonUser>.MemoType = {
switch persistType {
case .home: return .homeTimeline
case .public: return .publicTimeline
case .user: return .userTimeline
case .likeList: return .likeList
case .lookUp: return .lookUp
}
}()
var persistMemos: [PersistMemo<Toot, MastodonUser>] = []
var persistMemos: [PersistMemo<Status, MastodonUser>] = []
os_signpost(.begin, log: log, name: "update database", signpostID: updateDatabaseTaskSignpostID)
for entity in toots {
for entity in statuses {
let processEntityTaskSignpostID = OSSignpostID(log: log)
os_signpost(.begin, log: log, name: "update database - process entity", signpostID: processEntityTaskSignpostID, "process entity %{public}s", entity.id)
defer {
os_signpost(.end, log: log, name: "update database - process entity", signpostID: processEntityTaskSignpostID, "process entity %{public}s", entity.id)
}
let memo = PersistMemo.createOrMergeToot(
let memo = PersistMemo.createOrMergeStatus(
into: managedObjectContext,
for: requestMastodonUser,
requestMastodonUserID: requestMastodonUserID,
domain: domain,
entity: entity,
memoType: memoType,
tootCache: tootCache,
statusCache: statusCache,
userCache: userCache,
networkDate: response.networkDate,
log: log
@ -161,19 +163,19 @@ extension APIService.Persist {
}
// Task 1: update anchor hasMore
// update maxID anchor hasMore attribute when fetching on home timeline
// do not use working records due to anchor toot is removable on the remote
var anchorToot: Toot?
// do not use working records due to anchor status is removable on the remote
var anchorStatus: Status?
if let maxID = query.maxID {
do {
// load anchor toot from database
let request = Toot.sortedFetchRequest
request.predicate = Toot.predicate(domain: domain, id: maxID)
// load anchor status from database
let request = Status.sortedFetchRequest
request.predicate = Status.predicate(domain: domain, id: maxID)
request.returnsObjectsAsFaults = false
request.fetchLimit = 1
anchorToot = try managedObjectContext.fetch(request).first
anchorStatus = try managedObjectContext.fetch(request).first
if persistType == .home {
let timelineIndex = anchorToot.flatMap { toot in
toot.homeTimelineIndexes?.first(where: { $0.userID == requestMastodonUserID })
let timelineIndex = anchorStatus.flatMap { status in
status.homeTimelineIndexes?.first(where: { $0.userID == requestMastodonUserID })
}
timelineIndex?.update(hasMore: false)
} else {
@ -184,16 +186,16 @@ extension APIService.Persist {
}
}
// Task 2: set last toot hasMore when fetched toots not overlap with the timeline in the local database
// Task 2: set last status hasMore when fetched statuses not overlap with the timeline in the local database
let _oldestMemo = persistMemos
.sorted(by: { $0.status.createdAt < $1.status.createdAt })
.first
if let oldestMemo = _oldestMemo {
if let anchorToot = anchorToot {
if let anchorStatus = anchorStatus {
// using anchor. set hasMore when (overlap itself OR no overlap) AND oldest record NOT anchor
let isNoOverlap = mergedOldTootsInTimeline.isEmpty
let isOnlyOverlapItself = mergedOldTootsInTimeline.count == 1 && mergedOldTootsInTimeline.first?.id == anchorToot.id
let isAnchorEqualOldestRecord = oldestMemo.status.id == anchorToot.id
let isNoOverlap = mergedOldStatusesInTimeline.isEmpty
let isOnlyOverlapItself = mergedOldStatusesInTimeline.count == 1 && mergedOldStatusesInTimeline.first?.id == anchorStatus.id
let isAnchorEqualOldestRecord = oldestMemo.status.id == anchorStatus.id
if (isNoOverlap || isOnlyOverlapItself) && !isAnchorEqualOldestRecord {
if persistType == .home {
let timelineIndex = oldestMemo.status.homeTimelineIndexes?
@ -204,7 +206,7 @@ extension APIService.Persist {
}
}
} else if mergedOldTootsInTimeline.isEmpty {
} else if mergedOldStatusesInTimeline.isEmpty {
// no anchor. set hasMore when no overlap
if persistType == .home {
let timelineIndex = oldestMemo.status.homeTimelineIndexes?
@ -232,7 +234,7 @@ extension APIService.Persist {
let newTweetsInTimeLineCount = persistMemos.reduce(0, { result, next in
return next.statusProcessType == .create ? result + 1 : result
})
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: tweet: insert %{public}ldT(%{public}ldTRQ), merge %{public}ldT(%{public}ldTRQ)", ((#file as NSString).lastPathComponent), #line, #function, newTweetsInTimeLineCount, counting.status.create, mergedOldTootsInTimeline.count, counting.status.merge)
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: tweet: insert %{public}ldT(%{public}ldTRQ), merge %{public}ldT(%{public}ldTRQ)", ((#file as NSString).lastPathComponent), #line, #function, newTweetsInTimeLineCount, counting.status.create, mergedOldStatusesInTimeline.count, counting.status.merge)
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: twitter user: insert %{public}ld, merge %{public}ld", ((#file as NSString).lastPathComponent), #line, #function, counting.user.create, counting.user.merge)
}
#endif

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