forked from zelo72/mastodon-ios
Merge branch 'feat/hastagTimeline' of https://github.com/tootsuite/mastodon-ios into feat/hastagTimeline
This commit is contained in:
commit
412a7dc508
|
@ -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="20D75" 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="String"/>
|
||||
|
@ -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>
|
||||
</model>
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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?
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)]
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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>?
|
||||
|
|
|
@ -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",
|
||||
|
@ -162,6 +172,8 @@
|
|||
"title": "Some ground rules.",
|
||||
"subtitle": "These rules are set by the admins of %s.",
|
||||
"prompt": "By continuing, you're subject to the terms of service and privacy policy for %s.",
|
||||
"terms_of_service": "terms of service",
|
||||
"privacy_policy": "privacy policy",
|
||||
"button": {
|
||||
"confirm": "I Agree"
|
||||
}
|
||||
|
@ -235,6 +247,18 @@
|
|||
"direct": "Only people I mention"
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"dashboard": {
|
||||
"posts": "posts",
|
||||
"following": "following",
|
||||
"followers": "followers"
|
||||
},
|
||||
"segmented_control": {
|
||||
"posts": "Posts",
|
||||
"replies": "Replies",
|
||||
"media": "Media"
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"searchBar": {
|
||||
"placeholder": "Search hashtags and users",
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -57,7 +57,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 */; };
|
||||
|
@ -111,6 +111,8 @@
|
|||
2DF75BB925D1474100694EC8 /* ManagedObjectObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BB825D1474100694EC8 /* ManagedObjectObserver.swift */; };
|
||||
2DF75BC725D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */; };
|
||||
2DFF41892614A4DC00F776A4 /* UIView+Constraint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFF41882614A4DC00F776A4 /* UIView+Constraint.swift */; };
|
||||
5D0393902612D259007FE196 /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D03938F2612D259007FE196 /* WebViewController.swift */; };
|
||||
5D0393962612D266007FE196 /* WebViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D0393952612D266007FE196 /* WebViewModel.swift */; };
|
||||
5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 5D526FE125BE9AC400460CB9 /* MastodonSDK */; };
|
||||
5DF1054125F886D400D6C0D4 /* ViedeoPlaybackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */; };
|
||||
5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */; };
|
||||
|
@ -126,7 +128,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 */; };
|
||||
|
@ -142,6 +144,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 */; };
|
||||
|
@ -172,6 +177,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 */; };
|
||||
|
@ -190,7 +198,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 */; };
|
||||
|
@ -207,7 +215,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 */; };
|
||||
|
@ -222,7 +230,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 */; };
|
||||
|
@ -261,9 +269,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 */; };
|
||||
|
@ -374,7 +403,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>"; };
|
||||
|
@ -430,6 +459,8 @@
|
|||
3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
459EA4F43058CAB47719E963 /* Pods-Mastodon-MastodonUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
5D03938F2612D259007FE196 /* WebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewController.swift; sourceTree = "<group>"; };
|
||||
5D0393952612D266007FE196 /* WebViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewModel.swift; sourceTree = "<group>"; };
|
||||
5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViedeoPlaybackService.swift; sourceTree = "<group>"; };
|
||||
5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModel.swift; sourceTree = "<group>"; };
|
||||
5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVPlayer.swift; sourceTree = "<group>"; };
|
||||
|
@ -445,7 +476,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>"; };
|
||||
|
@ -462,6 +493,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; };
|
||||
|
@ -498,6 +532,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>"; };
|
||||
|
@ -515,7 +552,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>"; };
|
||||
|
@ -532,7 +569,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>"; };
|
||||
|
@ -549,7 +586,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>"; };
|
||||
|
@ -587,9 +624,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>"; };
|
||||
|
@ -609,6 +666,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 */,
|
||||
|
@ -899,6 +957,7 @@
|
|||
children = (
|
||||
2D76319D25C151F600929FB9 /* Section */,
|
||||
2D7631B125C159E700929FB9 /* Item */,
|
||||
DBCBED2226132E1D00B49291 /* FetchedResultsController */,
|
||||
);
|
||||
path = Diffiable;
|
||||
sourceTree = "<group>";
|
||||
|
@ -994,6 +1053,15 @@
|
|||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5D03938E2612D200007FE196 /* Webview */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5D03938F2612D259007FE196 /* WebViewController.swift */,
|
||||
5D0393952612D266007FE196 /* WebViewModel.swift */,
|
||||
);
|
||||
path = Webview;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB01409B25C40BB600F9F3CF /* Onboarding */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -1023,7 +1091,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
DB45FAE225CA7181005A8AC7 /* MastodonUser.swift */,
|
||||
DB084B5625CBC56C00F898ED /* Toot.swift */,
|
||||
DB084B5625CBC56C00F898ED /* Status.swift */,
|
||||
DB9D6C3725E508BE0051B173 /* Attachment.swift */,
|
||||
);
|
||||
path = CoreDataStack;
|
||||
|
@ -1104,6 +1172,7 @@
|
|||
children = (
|
||||
DB427DE325BAA00100D1B89D /* Info.plist */,
|
||||
DB89BA1025C10FF5008580ED /* Mastodon.entitlements */,
|
||||
DBCC3B7A261443AD0045B23D /* ViewController.swift */,
|
||||
2D76319C25C151DE00929FB9 /* Diffiable */,
|
||||
DB8AF52A25C13561002E6C99 /* State */,
|
||||
2D61335525C1886800CAE157 /* Service */,
|
||||
|
@ -1154,6 +1223,7 @@
|
|||
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 */,
|
||||
|
@ -1163,6 +1233,7 @@
|
|||
2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */,
|
||||
2D34D9DA261494120081BFC0 /* APIService+Search.swift */,
|
||||
0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */,
|
||||
DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */,
|
||||
);
|
||||
path = APIService;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1220,7 +1291,7 @@
|
|||
DB68A04F25E9028800CFDF14 /* NavigationController */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */,
|
||||
DB68A04925E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift */,
|
||||
);
|
||||
path = NavigationController;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1259,7 +1330,7 @@
|
|||
DB789A2125F9F76D0071ACA0 /* CollectionViewCell */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift */,
|
||||
DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift */,
|
||||
DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */,
|
||||
DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift */,
|
||||
DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */,
|
||||
|
@ -1317,7 +1388,7 @@
|
|||
DB89BA2C25C110B7008580ED /* Entity */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DB89BA2625C110B4008580ED /* Toot.swift */,
|
||||
DB89BA2625C110B4008580ED /* Status.swift */,
|
||||
DB8AF52425C131D1002E6C99 /* MastodonUser.swift */,
|
||||
DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */,
|
||||
2D927F0125C7E4F2004F19B8 /* Mention.swift */,
|
||||
|
@ -1329,6 +1400,7 @@
|
|||
DB9D6C2D25E504AC0051B173 /* Attachment.swift */,
|
||||
DB4481AC25EE155900BEFB67 /* Poll.swift */,
|
||||
DB4481B225EE16D000BEFB67 /* PollOption.swift */,
|
||||
DBCC3B9A2615849F0045B23D /* PrivateNote.swift */,
|
||||
);
|
||||
path = Entity;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1373,6 +1445,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
0F2021F5261325ED000C64BF /* HashtagTimeline */,
|
||||
5D03938E2612D200007FE196 /* Webview */,
|
||||
2D7631A425C1532200929FB9 /* Share */,
|
||||
DB8AF54E25C13703002E6C99 /* MainTab */,
|
||||
DB01409B25C40BB600F9F3CF /* Onboarding */,
|
||||
|
@ -1404,6 +1477,7 @@
|
|||
0FAA101B25E10E760017CCDE /* UIFont.swift */,
|
||||
2D939AB425EDD8A90076FA61 /* String.swift */,
|
||||
2D206B7F25F5F45E00143C56 /* UIImage.swift */,
|
||||
DBCC3B88261454BA0045B23D /* CGImage.swift */,
|
||||
2D206B8525F5FB0900143C56 /* Double.swift */,
|
||||
2D206B9125F60EA700143C56 /* UIControl.swift */,
|
||||
5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */,
|
||||
|
@ -1415,6 +1489,8 @@
|
|||
2D84350425FF858100EECE90 /* UIScrollView.swift */,
|
||||
DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */,
|
||||
0F20223826146553000C64BF /* Array+removeDuplicates.swift */,
|
||||
DBCC3B2F261440A50045B23D /* UITabBarController.swift */,
|
||||
DBCC3B35261440BA0045B23D /* UINavigationController.swift */,
|
||||
);
|
||||
path = Extension;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1469,7 +1545,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>";
|
||||
|
@ -1499,7 +1581,8 @@
|
|||
DB9E0D6925EDFFE500CFDD76 /* Helper */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2D42FF6A25C817D2004A627A /* TootContent.swift */,
|
||||
2D42FF6A25C817D2004A627A /* MastodonStatusContent.swift */,
|
||||
DB35FC2E26130172006193C9 /* MastodonField.swift */,
|
||||
);
|
||||
path = Helper;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1521,6 +1604,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 = (
|
||||
|
@ -1574,6 +1716,7 @@
|
|||
2D939AC725EE14620076FA61 /* CropViewController */,
|
||||
DB9A487D2603456B008B817C /* UITextView+Placeholder */,
|
||||
DBE64A8A260C49D200E6359A /* TwitterTextEditor */,
|
||||
DBB525072611EAC0002F1F29 /* Tabman */,
|
||||
);
|
||||
productName = Mastodon;
|
||||
productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */;
|
||||
|
@ -1705,6 +1848,7 @@
|
|||
2D939AC625EE14620076FA61 /* XCRemoteSwiftPackageReference "TOCropViewController" */,
|
||||
DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */,
|
||||
DBE64A89260C49D200E6359A /* XCRemoteSwiftPackageReference "TwitterTextEditor" */,
|
||||
DBB525062611EAC0002F1F29 /* XCRemoteSwiftPackageReference "Tabman" */,
|
||||
);
|
||||
productRefGroup = DB427DD325BAA00100D1B89D /* Products */;
|
||||
projectDirPath = "";
|
||||
|
@ -1889,6 +2033,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 */,
|
||||
|
@ -1896,16 +2042,20 @@
|
|||
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 */,
|
||||
5D0393962612D266007FE196 /* WebViewModel.swift in Sources */,
|
||||
2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */,
|
||||
DB1E347825F519300079D7DF /* PickServerItem.swift in Sources */,
|
||||
DB1FD45A25F27898004CFCFC /* CategoryPickerItem.swift in Sources */,
|
||||
|
@ -1917,6 +2067,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 */,
|
||||
0F1E2D0B2615C39400C38565 /* HashtagTimelineTitleView.swift in Sources */,
|
||||
DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */,
|
||||
|
@ -1930,6 +2081,7 @@
|
|||
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 */,
|
||||
|
@ -1937,14 +2089,17 @@
|
|||
DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */,
|
||||
2D61335825C188A000CAE157 /* APIService+Persist+Status.swift in Sources */,
|
||||
2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */,
|
||||
DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */,
|
||||
DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */,
|
||||
DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */,
|
||||
0F202213261351F5000C64BF /* APIService+HashtagTimeline.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 */,
|
||||
|
@ -1974,12 +2129,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 */,
|
||||
|
@ -1988,6 +2146,7 @@
|
|||
2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */,
|
||||
2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */,
|
||||
DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */,
|
||||
5D0393902612D259007FE196 /* WebViewController.swift in Sources */,
|
||||
DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */,
|
||||
DB44767B260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift in Sources */,
|
||||
0F20222D261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift in Sources */,
|
||||
|
@ -1997,10 +2156,12 @@
|
|||
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 */,
|
||||
2D34D9D126148D9E0081BFC0 /* APIService+Recommend.swift in Sources */,
|
||||
DBB525562611EDCA002F1F29 /* UserTimelineViewModel.swift in Sources */,
|
||||
2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */,
|
||||
DB221B16260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift in Sources */,
|
||||
DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */,
|
||||
|
@ -2016,11 +2177,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 */,
|
||||
|
@ -2029,16 +2191,19 @@
|
|||
2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */,
|
||||
2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */,
|
||||
0F20220D26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift in Sources */,
|
||||
DBCC3B8F26148F7B0045B23D /* CachedProfileViewModel.swift in Sources */,
|
||||
DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */,
|
||||
DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */,
|
||||
DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */,
|
||||
2D34D9CB261489930081BFC0 /* SearchViewController+recomendView.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 */,
|
||||
|
@ -2069,7 +2234,7 @@
|
|||
0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.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 */,
|
||||
|
@ -2079,8 +2244,10 @@
|
|||
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 */,
|
||||
0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */,
|
||||
DBCC3B30261440A50045B23D /* UITabBarController.swift in Sources */,
|
||||
DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */,
|
||||
DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */,
|
||||
DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */,
|
||||
|
@ -2088,19 +2255,23 @@
|
|||
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 */,
|
||||
2D6DE40026141DF600A63F6A /* SearchViewModel.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;
|
||||
};
|
||||
|
@ -2131,11 +2302,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 */,
|
||||
|
@ -2695,6 +2867,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";
|
||||
|
@ -2750,6 +2930,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" */;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -46,12 +46,16 @@ extension SceneCoordinator {
|
|||
case mastodonServerRules(viewModel: MastodonServerRulesViewModel)
|
||||
case mastodonConfirmEmail(viewModel: MastodonConfirmEmailViewModel)
|
||||
case mastodonResendEmail(viewModel: MastodonResendEmailViewModel)
|
||||
case mastodonWebView(viewModel:WebViewModel)
|
||||
|
||||
// compose
|
||||
case compose(viewModel: ComposeViewModel)
|
||||
|
||||
// Hashtag Timeline
|
||||
case hashtagTimeline(viewModel: HashtagTimelineViewModel)
|
||||
|
||||
// profile
|
||||
case profile(viewModel: ProfileViewModel)
|
||||
|
||||
// misc
|
||||
case alertController(alertController: UIAlertController)
|
||||
|
@ -122,17 +126,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
|
||||
}
|
||||
|
@ -149,12 +154,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)
|
||||
}
|
||||
|
||||
|
@ -196,10 +204,18 @@ private extension SceneCoordinator {
|
|||
let _viewController = MastodonResendEmailViewController()
|
||||
_viewController.viewModel = viewModel
|
||||
viewController = _viewController
|
||||
case .mastodonWebView(let viewModel):
|
||||
let _viewController = WebViewController()
|
||||
_viewController.viewModel = viewModel
|
||||
viewController = _viewController
|
||||
case .compose(let viewModel):
|
||||
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(
|
||||
|
|
|
@ -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 objectIDs = 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.objectIDs.value = items
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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,
|
|
@ -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 }
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
|
|
|
@ -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")
|
||||
|
@ -269,6 +287,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")
|
||||
|
@ -416,6 +452,8 @@ internal enum L10n {
|
|||
}
|
||||
}
|
||||
internal enum ServerRules {
|
||||
/// privacy policy
|
||||
internal static let privacyPolicy = L10n.tr("Localizable", "Scene.ServerRules.PrivacyPolicy")
|
||||
/// By continuing, you're subject to the terms of service and privacy policy for %@.
|
||||
internal static func prompt(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "Scene.ServerRules.Prompt", String(describing: p1))
|
||||
|
@ -424,6 +462,8 @@ internal enum L10n {
|
|||
internal static func subtitle(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "Scene.ServerRules.Subtitle", String(describing: p1))
|
||||
}
|
||||
/// terms of service
|
||||
internal static let termsOfService = L10n.tr("Localizable", "Scene.ServerRules.TermsOfService")
|
||||
/// Some ground rules.
|
||||
internal static let title = L10n.tr("Localizable", "Scene.ServerRules.Title")
|
||||
internal enum Button {
|
||||
|
|
|
@ -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]
|
||||
}
|
||||
}
|
|
@ -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) {
|
|
@ -73,7 +73,5 @@
|
|||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
@ -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 }) }
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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";
|
|
@ -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";
|
||||
|
@ -86,6 +94,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";
|
||||
|
@ -130,8 +144,10 @@ tap the link to confirm your account.";
|
|||
"Scene.ServerPicker.Title" = "Pick a Server,
|
||||
any server.";
|
||||
"Scene.ServerRules.Button.Confirm" = "I Agree";
|
||||
"Scene.ServerRules.PrivacyPolicy" = "privacy policy";
|
||||
"Scene.ServerRules.Prompt" = "By continuing, you're subject to the terms of service and privacy policy for %@.";
|
||||
"Scene.ServerRules.Subtitle" = "These rules are set by the admins of %@.";
|
||||
"Scene.ServerRules.TermsOfService" = "terms of service";
|
||||
"Scene.ServerRules.Title" = "Some ground rules.";
|
||||
"Scene.Welcome.Slogan" = "Social networking
|
||||
back in your hands.";
|
|
@ -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() {
|
||||
|
|
@ -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))
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)) }
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -66,6 +66,10 @@ final class MastodonConfirmEmailViewController: UIViewController, NeedsDependenc
|
|||
}
|
||||
|
||||
extension MastodonConfirmEmailViewController {
|
||||
|
||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||
return .darkContent
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
|
||||
|
|
|
@ -57,6 +57,10 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency
|
|||
|
||||
extension MastodonPickServerViewController {
|
||||
|
||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||
return .darkContent
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
|
|
|
@ -235,6 +235,10 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O
|
|||
|
||||
extension MastodonRegisterViewController {
|
||||
|
||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||
return .darkContent
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -8,6 +8,8 @@
|
|||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
import MastodonSDK
|
||||
import SafariServices
|
||||
|
||||
final class MastodonServerRulesViewController: UIViewController, NeedsDependency {
|
||||
|
||||
|
@ -44,19 +46,20 @@ final class MastodonServerRulesViewController: UIViewController, NeedsDependency
|
|||
return label
|
||||
}()
|
||||
|
||||
let bottonContainerView: UIView = {
|
||||
let bottomContainerView: UIView = {
|
||||
let view = UIView()
|
||||
view.backgroundColor = Asset.Colors.Background.onboardingBackground.color
|
||||
return view
|
||||
}()
|
||||
|
||||
private(set) lazy var bottomPromptLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = .preferredFont(forTextStyle: .body)
|
||||
label.textColor = .label
|
||||
label.text = L10n.Scene.ServerRules.prompt(viewModel.domain)
|
||||
label.numberOfLines = 0
|
||||
return label
|
||||
private(set) lazy var bottomPromptTextView: UITextView = {
|
||||
let textView = UITextView()
|
||||
textView.font = .preferredFont(forTextStyle: .body)
|
||||
textView.textColor = .label
|
||||
textView.isSelectable = true
|
||||
textView.isEditable = false
|
||||
textView.backgroundColor = Asset.Colors.Background.onboardingBackground.color
|
||||
return textView
|
||||
}()
|
||||
|
||||
let confirmButton: PrimaryActionButton = {
|
||||
|
@ -82,40 +85,47 @@ final class MastodonServerRulesViewController: UIViewController, NeedsDependency
|
|||
|
||||
extension MastodonServerRulesViewController {
|
||||
|
||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||
return .darkContent
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
setupOnboardingAppearance()
|
||||
configTextView()
|
||||
|
||||
defer { setupNavigationBarBackgroundView() }
|
||||
|
||||
bottonContainerView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(bottonContainerView)
|
||||
bottomContainerView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(bottomContainerView)
|
||||
NSLayoutConstraint.activate([
|
||||
view.bottomAnchor.constraint(equalTo: bottonContainerView.bottomAnchor),
|
||||
bottonContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
bottonContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
view.bottomAnchor.constraint(equalTo: bottomContainerView.bottomAnchor),
|
||||
bottomContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
bottomContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
])
|
||||
bottonContainerView.preservesSuperviewLayoutMargins = true
|
||||
bottomContainerView.preservesSuperviewLayoutMargins = true
|
||||
defer {
|
||||
view.bringSubviewToFront(bottonContainerView)
|
||||
view.bringSubviewToFront(bottomContainerView)
|
||||
}
|
||||
|
||||
confirmButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
bottonContainerView.addSubview(confirmButton)
|
||||
bottomContainerView.addSubview(confirmButton)
|
||||
NSLayoutConstraint.activate([
|
||||
bottonContainerView.layoutMarginsGuide.bottomAnchor.constraint(equalTo: confirmButton.bottomAnchor, constant: MastodonServerRulesViewController.viewBottomPaddingHeight),
|
||||
confirmButton.leadingAnchor.constraint(equalTo: bottonContainerView.readableContentGuide.leadingAnchor, constant: MastodonServerRulesViewController.actionButtonMargin),
|
||||
bottonContainerView.readableContentGuide.trailingAnchor.constraint(equalTo: confirmButton.trailingAnchor, constant: MastodonServerRulesViewController.actionButtonMargin),
|
||||
bottomContainerView.layoutMarginsGuide.bottomAnchor.constraint(equalTo: confirmButton.bottomAnchor, constant: MastodonServerRulesViewController.viewBottomPaddingHeight),
|
||||
confirmButton.leadingAnchor.constraint(equalTo: bottomContainerView.readableContentGuide.leadingAnchor, constant: MastodonServerRulesViewController.actionButtonMargin),
|
||||
bottomContainerView.readableContentGuide.trailingAnchor.constraint(equalTo: confirmButton.trailingAnchor, constant: MastodonServerRulesViewController.actionButtonMargin),
|
||||
confirmButton.heightAnchor.constraint(equalToConstant: MastodonServerRulesViewController.actionButtonHeight).priority(.defaultHigh),
|
||||
])
|
||||
|
||||
bottomPromptLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
bottonContainerView.addSubview(bottomPromptLabel)
|
||||
bottomPromptTextView.translatesAutoresizingMaskIntoConstraints = false
|
||||
bottomContainerView.addSubview(bottomPromptTextView)
|
||||
NSLayoutConstraint.activate([
|
||||
bottomPromptLabel.topAnchor.constraint(equalTo: bottonContainerView.topAnchor, constant: 20),
|
||||
bottomPromptLabel.leadingAnchor.constraint(equalTo: bottonContainerView.readableContentGuide.leadingAnchor),
|
||||
bottomPromptLabel.trailingAnchor.constraint(equalTo: bottonContainerView.readableContentGuide.trailingAnchor),
|
||||
confirmButton.topAnchor.constraint(equalTo: bottomPromptLabel.bottomAnchor, constant: 20),
|
||||
bottomPromptTextView.frameLayoutGuide.topAnchor.constraint(equalTo: bottomContainerView.topAnchor, constant: 20),
|
||||
bottomPromptTextView.frameLayoutGuide.leadingAnchor.constraint(equalTo: bottomContainerView.readableContentGuide.leadingAnchor),
|
||||
bottomPromptTextView.frameLayoutGuide.trailingAnchor.constraint(equalTo: bottomContainerView.readableContentGuide.trailingAnchor),
|
||||
bottomPromptTextView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: 50),
|
||||
confirmButton.topAnchor.constraint(equalTo: bottomPromptTextView.frameLayoutGuide.bottomAnchor, constant: 20),
|
||||
])
|
||||
|
||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
@ -165,8 +175,32 @@ extension MastodonServerRulesViewController {
|
|||
extension MastodonServerRulesViewController {
|
||||
func updateScrollViewContentInset() {
|
||||
view.layoutIfNeeded()
|
||||
scrollView.contentInset.bottom = bottonContainerView.frame.height
|
||||
scrollView.verticalScrollIndicatorInsets.bottom = bottonContainerView.frame.height
|
||||
scrollView.contentInset.bottom = bottomContainerView.frame.height
|
||||
scrollView.verticalScrollIndicatorInsets.bottom = bottomContainerView.frame.height
|
||||
}
|
||||
|
||||
func configTextView() {
|
||||
let linkColor = Asset.Colors.Button.normal.color
|
||||
|
||||
let str = NSString(string: L10n.Scene.ServerRules.prompt(viewModel.domain))
|
||||
let termsOfServiceRange = str.range(of: L10n.Scene.ServerRules.termsOfService)
|
||||
let privacyRange = str.range(of: L10n.Scene.ServerRules.privacyPolicy)
|
||||
let attributeString = NSMutableAttributedString(string: L10n.Scene.ServerRules.prompt(viewModel.domain), attributes: [NSAttributedString.Key.font: UIFont.preferredFont(forTextStyle: .body), NSAttributedString.Key.foregroundColor: UIColor.label])
|
||||
attributeString.addAttribute(.link, value: Mastodon.API.serverRulesURL(domain: viewModel.domain), range: termsOfServiceRange)
|
||||
attributeString.addAttribute(.link, value: Mastodon.API.privacyURL(domain: viewModel.domain), range: privacyRange)
|
||||
let linkAttributes = [NSAttributedString.Key.foregroundColor:linkColor]
|
||||
bottomPromptTextView.attributedText = attributeString
|
||||
bottomPromptTextView.linkTextAttributes = linkAttributes
|
||||
bottomPromptTextView.delegate = self
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MastodonServerRulesViewController: UITextViewDelegate {
|
||||
func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
|
||||
let safariVC = SFSafariViewController(url: URL)
|
||||
self.present(safariVC, animated: true, completion: nil)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -35,13 +35,16 @@ final class MastodonServerRulesViewModel {
|
|||
|
||||
var rulesAttributedString: NSAttributedString {
|
||||
let attributedString = NSMutableAttributedString(string: "\n")
|
||||
let configuration = UIImage.SymbolConfiguration(font: .preferredFont(forTextStyle: .title3))
|
||||
for (i, rule) in rules.enumerated() {
|
||||
let index = String(i + 1)
|
||||
let indexString = NSAttributedString(string: index + ". ", attributes: [
|
||||
NSAttributedString.Key.foregroundColor: UIColor.secondaryLabel
|
||||
])
|
||||
let ruleString = NSAttributedString(string: rule.text + "\n\n")
|
||||
attributedString.append(indexString)
|
||||
let imageName = String(i + 1) + ".circle.fill"
|
||||
let image = UIImage(systemName: imageName, withConfiguration: configuration)!
|
||||
let attachment = NSTextAttachment()
|
||||
attachment.image = image.withTintColor(.black)
|
||||
let imageAttribute = NSAttributedString(attachment: attachment)
|
||||
|
||||
let ruleString = NSAttributedString(string: " " + rule.text + "\n\n")
|
||||
attributedString.append(imageAttribute)
|
||||
attributedString.append(ruleString)
|
||||
}
|
||||
return attributedString
|
||||
|
|
|
@ -64,6 +64,10 @@ final class WelcomeViewController: UIViewController, NeedsDependency {
|
|||
|
||||
extension WelcomeViewController {
|
||||
|
||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||
return .darkContent
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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),
|
||||
])
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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 }
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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.objectIDs
|
||||
.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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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)) }
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import UIKit
|
||||
|
||||
final class RoundedEdgesButton: UIButton {
|
||||
class RoundedEdgesButton: UIButton {
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
|
|
@ -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)
|
||||
|
@ -196,8 +198,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)
|
||||
|
@ -227,7 +230,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
|
||||
|
@ -403,6 +406,12 @@ extension StatusView {
|
|||
playerContainerView.delegate = self
|
||||
activeTextLabel.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)
|
||||
}
|
||||
|
@ -441,6 +450,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)
|
||||
|
|
|
@ -21,6 +21,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)
|
||||
|
@ -197,6 +199,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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,67 @@
|
|||
//
|
||||
// WebViewController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by xiaojian sun on 2021/3/30.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import os.log
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
final class WebViewController: UIViewController, NeedsDependency {
|
||||
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
var viewModel: WebViewModel!
|
||||
|
||||
let webView: WKWebView = {
|
||||
let configuration = WKWebViewConfiguration()
|
||||
configuration.processPool = WKProcessPool()
|
||||
let webView = WKWebView(frame: .zero, configuration: configuration)
|
||||
return webView
|
||||
}()
|
||||
|
||||
deinit {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function)
|
||||
|
||||
// cleanup cookie
|
||||
let httpCookieStore = webView.configuration.websiteDataStore.httpCookieStore
|
||||
httpCookieStore.getAllCookies { cookies in
|
||||
for cookie in cookies {
|
||||
httpCookieStore.delete(cookie, completionHandler: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension WebViewController {
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(WebViewController.cancelBarButtonItemPressed(_:)))
|
||||
|
||||
webView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(webView)
|
||||
NSLayoutConstraint.activate([
|
||||
webView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
webView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
webView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
|
||||
let request = URLRequest(url: viewModel.url)
|
||||
webView.load(request)
|
||||
}
|
||||
}
|
||||
|
||||
extension WebViewController {
|
||||
@objc private func cancelBarButtonItemPressed(_ sender: UIBarButtonItem) {
|
||||
dismiss(animated: true, completion: nil)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
//
|
||||
// WebViewModel.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by xiaojian sun on 2021/3/30.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
final class WebViewModel {
|
||||
public init(url: URL) {
|
||||
self.url = url
|
||||
}
|
||||
|
||||
// input
|
||||
let url: URL
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
|
@ -44,7 +44,7 @@ final class APIService {
|
|||
}
|
||||
|
||||
extension APIService {
|
||||
public static let onceRequestTootMaxCount = 100
|
||||
public static let onceRequestStatusMaxCount = 100
|
||||
public static let onceRequestUserMaxCount = 100
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue