forked from zelo72/mastodon-ios
Merge branch 'release/0.9.0'
This commit is contained in:
commit
b7a5e9e2e4
|
@ -136,6 +136,7 @@
|
|||
<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="searchHistory" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SearchHistory" inverseName="account" inverseEntity="SearchHistory"/>
|
||||
<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"/>
|
||||
|
@ -182,8 +183,9 @@
|
|||
<attribute name="createAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser"/>
|
||||
<relationship name="hashtag" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Tag"/>
|
||||
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="searchHistory" inverseEntity="MastodonUser"/>
|
||||
<relationship name="hashtag" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Tag" inverseName="searchHistory" inverseEntity="Tag"/>
|
||||
<relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="searchHistory" inverseEntity="Status"/>
|
||||
</entity>
|
||||
<entity name="Setting" representedClassName=".Setting" syncable="YES">
|
||||
<attribute name="appearanceRaw" attributeType="String"/>
|
||||
|
@ -234,6 +236,7 @@
|
|||
<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="Status" inverseName="replyTo" inverseEntity="Status"/>
|
||||
<relationship name="replyTo" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="replyFrom" inverseEntity="Status"/>
|
||||
<relationship name="searchHistory" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SearchHistory" inverseName="status" inverseEntity="SearchHistory"/>
|
||||
<relationship name="tags" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Tag" inverseName="statuses" inverseEntity="Tag"/>
|
||||
</entity>
|
||||
<entity name="Subscription" representedClassName=".Subscription" syncable="YES">
|
||||
|
@ -266,6 +269,7 @@
|
|||
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="url" attributeType="String"/>
|
||||
<relationship name="histories" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="History" inverseName="tag" inverseEntity="History"/>
|
||||
<relationship name="searchHistory" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SearchHistory" inverseName="hashtag" inverseEntity="SearchHistory"/>
|
||||
<relationship name="statuses" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="tags" inverseEntity="Status"/>
|
||||
</entity>
|
||||
<elements>
|
||||
|
@ -277,16 +281,16 @@
|
|||
<element name="HomeTimelineIndex" positionX="0" positionY="0" width="128" height="134"/>
|
||||
<element name="MastodonAuthentication" positionX="0" positionY="0" width="128" height="209"/>
|
||||
<element name="MastodonNotification" positionX="9" positionY="162" width="128" height="164"/>
|
||||
<element name="MastodonUser" positionX="0" positionY="0" width="128" height="704"/>
|
||||
<element name="MastodonUser" positionX="0" positionY="0" width="128" height="719"/>
|
||||
<element name="Mention" positionX="0" positionY="0" width="128" height="149"/>
|
||||
<element name="Poll" positionX="0" positionY="0" width="128" height="194"/>
|
||||
<element name="PollOption" positionX="0" positionY="0" width="128" height="134"/>
|
||||
<element name="PrivateNote" positionX="0" positionY="0" width="128" height="89"/>
|
||||
<element name="SearchHistory" positionX="0" positionY="0" width="128" height="104"/>
|
||||
<element name="SearchHistory" positionX="0" positionY="0" width="128" height="119"/>
|
||||
<element name="Setting" positionX="72" positionY="162" width="128" height="164"/>
|
||||
<element name="Status" positionX="0" positionY="0" width="128" height="599"/>
|
||||
<element name="Status" positionX="0" positionY="0" width="128" height="614"/>
|
||||
<element name="Subscription" positionX="81" positionY="171" width="128" height="179"/>
|
||||
<element name="SubscriptionAlerts" positionX="72" positionY="162" width="128" height="14"/>
|
||||
<element name="Tag" positionX="0" positionY="0" width="128" height="134"/>
|
||||
<element name="Tag" positionX="0" positionY="0" width="128" height="149"/>
|
||||
</elements>
|
||||
</model>
|
|
@ -43,6 +43,7 @@ final public class MastodonUser: NSManagedObject {
|
|||
// one-to-one relationship
|
||||
@NSManaged public private(set) var pinnedStatus: Status?
|
||||
@NSManaged public private(set) var mastodonAuthentication: MastodonAuthentication?
|
||||
@NSManaged public private(set) var searchHistory: SearchHistory?
|
||||
|
||||
// one-to-many relationship
|
||||
@NSManaged public private(set) var statuses: Set<Status>?
|
||||
|
|
|
@ -13,9 +13,11 @@ public final class SearchHistory: NSManagedObject {
|
|||
@NSManaged public private(set) var identifier: ID
|
||||
@NSManaged public private(set) var createAt: Date
|
||||
@NSManaged public private(set) var updatedAt: Date
|
||||
|
||||
|
||||
// one-to-one relationship
|
||||
@NSManaged public private(set) var account: MastodonUser?
|
||||
@NSManaged public private(set) var hashtag: Tag?
|
||||
@NSManaged public private(set) var status: Status?
|
||||
|
||||
}
|
||||
|
||||
|
@ -51,6 +53,16 @@ extension SearchHistory {
|
|||
searchHistory.hashtag = hashtag
|
||||
return searchHistory
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public static func insert(
|
||||
into context: NSManagedObjectContext,
|
||||
status: Status
|
||||
) -> SearchHistory {
|
||||
let searchHistory: SearchHistory = context.insertObject()
|
||||
searchHistory.status = status
|
||||
return searchHistory
|
||||
}
|
||||
}
|
||||
|
||||
public extension SearchHistory {
|
||||
|
|
|
@ -38,20 +38,21 @@ public final class Status: NSManagedObject {
|
|||
@NSManaged public private(set) var language: String? // (ISO 639 Part 1 two-letter language code)
|
||||
@NSManaged public private(set) var text: String?
|
||||
|
||||
// many-to-one relastionship
|
||||
// many-to-one relationship
|
||||
@NSManaged public private(set) var author: MastodonUser
|
||||
@NSManaged public private(set) var reblog: Status?
|
||||
@NSManaged public private(set) var replyTo: Status?
|
||||
|
||||
// many-to-many relastionship
|
||||
// many-to-many relationship
|
||||
@NSManaged public private(set) var favouritedBy: Set<MastodonUser>?
|
||||
@NSManaged public private(set) var rebloggedBy: Set<MastodonUser>?
|
||||
@NSManaged public private(set) var mutedBy: Set<MastodonUser>?
|
||||
@NSManaged public private(set) var bookmarkedBy: Set<MastodonUser>?
|
||||
|
||||
// one-to-one relastionship
|
||||
// one-to-one relationship
|
||||
@NSManaged public private(set) var pinnedBy: MastodonUser?
|
||||
@NSManaged public private(set) var poll: Poll?
|
||||
@NSManaged public private(set) var searchHistory: SearchHistory?
|
||||
|
||||
// one-to-many relationship
|
||||
@NSManaged public private(set) var reblogFrom: Set<Status>?
|
||||
|
|
|
@ -17,6 +17,9 @@ public final class Tag: NSManagedObject {
|
|||
@NSManaged public private(set) var name: String
|
||||
@NSManaged public private(set) var url: String
|
||||
|
||||
// one-to-one relationship
|
||||
@NSManaged public private(set) var searchHistory: SearchHistory?
|
||||
|
||||
// many-to-many relationship
|
||||
@NSManaged public private(set) var statuses: Set<Status>?
|
||||
|
||||
|
|
|
@ -16,14 +16,14 @@
|
|||
"poll_expired": "The poll has expired"
|
||||
},
|
||||
"discard_post_content": {
|
||||
"title": "Discard Publish",
|
||||
"message": "Confirm discard composed post content."
|
||||
"title": "Discard Draft",
|
||||
"message": "Confirm to discard composed post content."
|
||||
},
|
||||
"publish_post_failure": {
|
||||
"title": "Publish Failure",
|
||||
"message": "Failed to publish the post.\nPlease check your internet connection.",
|
||||
"attchments_message": {
|
||||
"video_attach_with_photo": "Cannot attach a video to a status that already contains images.",
|
||||
"video_attach_with_photo": "Cannot attach a video to a post that already contains images.",
|
||||
"more_than_one_video": "Cannot attach more than one video."
|
||||
}
|
||||
},
|
||||
|
@ -32,17 +32,17 @@
|
|||
"message": "Cannot edit profile. Please try again."
|
||||
},
|
||||
"sign_out": {
|
||||
"title": "Sign out",
|
||||
"title": "Sign Out",
|
||||
"message": "Are you sure you want to sign out?",
|
||||
"confirm": "Sign Out"
|
||||
},
|
||||
"block_domain": {
|
||||
"title": "Are you really, really sure you want to block the entire %s? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.",
|
||||
"block_entire_domain": "Block entire domain"
|
||||
"title": "Are you really, really sure you want to block the entire %s? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain and any of your followers from that domain will be removed.",
|
||||
"block_entire_domain": "Block Domain"
|
||||
},
|
||||
"save_photo_failure": {
|
||||
"title": "Save Photo Failure",
|
||||
"message": "Please enable photo libaray access permission to save photo."
|
||||
"message": "Please enable the photo library access permission to save the photo."
|
||||
},
|
||||
"delete_post": {
|
||||
"title": "Are you sure you want to delete this post?",
|
||||
|
@ -50,7 +50,7 @@
|
|||
},
|
||||
"clean_cache": {
|
||||
"title": "Clean Cache",
|
||||
"message": "Successfully clean %s cache."
|
||||
"message": "Successfully cleaned %s cache."
|
||||
}
|
||||
},
|
||||
"controls": {
|
||||
|
@ -105,14 +105,14 @@
|
|||
"open_settings": "Open Settings"
|
||||
},
|
||||
"timeline": {
|
||||
"previous_status": "Previous Status",
|
||||
"next_status": "Next Status",
|
||||
"open_status": "Open Status",
|
||||
"open_author_profile": "Open Author Profile",
|
||||
"open_reblogger_profile": "Open Reblogger Profile",
|
||||
"reply_status": "Reply Status",
|
||||
"toggle_reblog": "Toggle Status Reblog",
|
||||
"toggle_favorite": "Toggle Status Favorite",
|
||||
"previous_status": "Previous Post",
|
||||
"next_status": "Next Post",
|
||||
"open_status": "Open Post",
|
||||
"open_author_profile": "Open Author's Profile",
|
||||
"open_reblogger_profile": "Open Reblogger's Profile",
|
||||
"reply_status": "Reply to Post",
|
||||
"toggle_reblog": "Toggle Reblog on Post",
|
||||
"toggle_favorite": "Toggle Favorite on Post",
|
||||
"toggle_content_warning": "Toggle Content Warning",
|
||||
"preview_image": "Preview Image"
|
||||
},
|
||||
|
@ -136,7 +136,7 @@
|
|||
"actions": {
|
||||
"reply": "Reply",
|
||||
"reblog": "Reblog",
|
||||
"unreblog": "Unreblog",
|
||||
"unreblog": "Undo reblog",
|
||||
"favorite": "Favorite",
|
||||
"unfavorite": "Unfavorite",
|
||||
"menu": "Menu"
|
||||
|
@ -180,12 +180,12 @@
|
|||
"show_more_replies": "Show more replies"
|
||||
},
|
||||
"header": {
|
||||
"no_status_found": "No Status Found",
|
||||
"blocking_warning": "You can’t view this profile\n until you unblock them.\nYour account looks like this to them.",
|
||||
"user_blocking_warning": "You can’t view %s’s profile\n until you unblock them.\nYour account looks like this to them.",
|
||||
"blocked_warning": "You can’t view this’s profile\n until they unblock you.",
|
||||
"no_status_found": "No Post Found",
|
||||
"blocking_warning": "You can’t view this user's profile\n until you unblock them.\nYour profile looks like this to them.",
|
||||
"user_blocking_warning": "You can’t view %s’s profile\n until you unblock them.\nYour profile looks like this to them.",
|
||||
"blocked_warning": "You can’t view this user’s profile\n until they unblock you.",
|
||||
"user_blocked_warning": "You can’t view %s’s profile\n until they unblock you.",
|
||||
"suspended_warning": "This account has been suspended.",
|
||||
"suspended_warning": "This user has been suspended.",
|
||||
"user_suspended_warning": "%s’s account has been suspended."
|
||||
},
|
||||
"accessibility": {
|
||||
|
@ -232,7 +232,7 @@
|
|||
},
|
||||
"empty_state": {
|
||||
"finding_servers": "Finding available servers...",
|
||||
"bad_network": "Something went wrong while loading data. Check your internet connection.",
|
||||
"bad_network": "Something went wrong while loading the data. Check your internet connection.",
|
||||
"no_results": "No results"
|
||||
}
|
||||
},
|
||||
|
@ -270,7 +270,7 @@
|
|||
"reason": "Reason"
|
||||
},
|
||||
"reason": {
|
||||
"blocked": "%s contains a disallowed e-mail provider",
|
||||
"blocked": "%s contains a disallowed email provider",
|
||||
"unreachable": "%s does not seem to exist",
|
||||
"taken": "%s is already in use",
|
||||
"reserved": "%s is a reserved keyword",
|
||||
|
@ -284,7 +284,7 @@
|
|||
"special": {
|
||||
"username_invalid": "Username must only contain alphanumeric characters and underscores",
|
||||
"username_too_long": "Username is too long (can’t be longer than 30 characters)",
|
||||
"email_invalid": "This is not a valid e-mail address",
|
||||
"email_invalid": "This is not a valid email address",
|
||||
"password_too_short": "Password is too short (must be at least 8 characters)"
|
||||
}
|
||||
}
|
||||
|
@ -351,8 +351,8 @@
|
|||
"photo": "photo",
|
||||
"video": "video",
|
||||
"attachment_broken": "This %s is broken and can’t be\nuploaded to Mastodon.",
|
||||
"description_photo": "Describe photo for low vision people...",
|
||||
"description_video": "Describe what’s happening for low vision people..."
|
||||
"description_photo": "Describe the photo for the visually-impaired...",
|
||||
"description_video": "Describe the video for the visually-impaired..."
|
||||
},
|
||||
"poll": {
|
||||
"duration_time": "Duration: %s",
|
||||
|
@ -377,13 +377,13 @@
|
|||
"space_to_add": "Space to add"
|
||||
},
|
||||
"accessibility": {
|
||||
"append_attachment": "Append attachment",
|
||||
"append_poll": "Append poll",
|
||||
"remove_poll": "Remove poll",
|
||||
"custom_emoji_picker": "Custom emoji picker",
|
||||
"enable_content_warning": "Enable content warning",
|
||||
"disable_content_warning": "Disable content warning",
|
||||
"post_visibility_menu": "Post visibility menu",
|
||||
"append_attachment": "Add Attachment",
|
||||
"append_poll": "Add Poll",
|
||||
"remove_poll": "Remove Poll",
|
||||
"custom_emoji_picker": "Custom Emoji Picker",
|
||||
"enable_content_warning": "Enable Content Warning",
|
||||
"disable_content_warning": "Disable Content Warning",
|
||||
"post_visibility_menu": "Post Visibility Menu",
|
||||
"input_limit_remains_count": "Input limit remains %ld",
|
||||
"input_limit_exceeds_count": "Input limit exceeds %ld"
|
||||
},
|
||||
|
@ -392,7 +392,7 @@
|
|||
"publish_post": "Publish Post",
|
||||
"toggle_poll": "Toggle Poll",
|
||||
"toggle_content_warning": "Toggle Content Warning",
|
||||
"append_attachment_entry": "Append Attachment - %s",
|
||||
"append_attachment_entry": "Add Attachment - %s",
|
||||
"select_visibility_entry": "Select Visibility - %s"
|
||||
}
|
||||
},
|
||||
|
@ -422,24 +422,25 @@
|
|||
"relationship_action_alert": {
|
||||
"confirm_unmute_user": {
|
||||
"title": "Unmute Account",
|
||||
"message": "Confirm unmute %s"
|
||||
"message": "Confirm to unmute %s"
|
||||
},
|
||||
"confirm_unblock_usre": {
|
||||
"title": "Unblock Account",
|
||||
"message": "Confirm unblock %s"
|
||||
"message": "Confirm to unblock %s"
|
||||
}
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"searchBar": {
|
||||
"title": "Search",
|
||||
"search_bar": {
|
||||
"placeholder": "Search hashtags and users",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"recommend": {
|
||||
"button_text": "See All",
|
||||
"hash_tag": {
|
||||
"title": "Trending in your timeline",
|
||||
"description": "Hashtags that are getting quite a bit of attention among people you follow",
|
||||
"title": "Trending on Mastodon",
|
||||
"description": "Hashtags that are getting quite a bit of attention",
|
||||
"people_talking": "%s people are talking"
|
||||
},
|
||||
"accounts": {
|
||||
|
@ -452,7 +453,11 @@
|
|||
"segment": {
|
||||
"all": "All",
|
||||
"people": "People",
|
||||
"hashtags": "Hashtags"
|
||||
"hashtags": "Hashtags",
|
||||
"posts": "Posts"
|
||||
},
|
||||
"empty_state": {
|
||||
"no_results": "No results"
|
||||
},
|
||||
"recent_search": "Recent searches",
|
||||
"clear": "Clear"
|
||||
|
@ -472,10 +477,10 @@
|
|||
"action": {
|
||||
"follow": "followed you",
|
||||
"favourite": "favorited your post",
|
||||
"reblog": "rebloged your post",
|
||||
"reblog": "reblogged your post",
|
||||
"poll": "Your poll has ended",
|
||||
"mention": "mentioned you",
|
||||
"follow_request": "request to follow you"
|
||||
"follow_request": "requested to follow you"
|
||||
},
|
||||
"keyobard": {
|
||||
"show_everything": "Show Everything",
|
||||
|
@ -496,8 +501,8 @@
|
|||
"dark": "Always Dark"
|
||||
},
|
||||
"appearance_settings": {
|
||||
"true_black_dark_mode": "True black Dark Mode",
|
||||
"disable_avatar_animation": "Disable avatar animation"
|
||||
"true_black_dark_mode": "True black dark mode",
|
||||
"disable_avatar_animation": "Disable animated avatars"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
|
@ -514,16 +519,17 @@
|
|||
}
|
||||
},
|
||||
"preference": {
|
||||
"title": "Preference",
|
||||
"using_default_browser": "Using default browser open link"
|
||||
"title": "Preferences",
|
||||
"using_default_browser": "Use default browser to open links"
|
||||
},
|
||||
"boringzone": {
|
||||
"title": "The Boring zone",
|
||||
"boring_zone": {
|
||||
"title": "The Boring Zone",
|
||||
"account_settings": "Account settings",
|
||||
"terms": "Terms of Service",
|
||||
"privacy": "Privacy Policy"
|
||||
},
|
||||
"spicyzone": {
|
||||
"title": "The spicy zone",
|
||||
"spicy_zone": {
|
||||
"title": "The Spicy Zone",
|
||||
"clear": "Clear Media Cache",
|
||||
"signout": "Sign Out"
|
||||
}
|
||||
|
|
|
@ -38,7 +38,6 @@
|
|||
2D152A9225C2980C009AA50C /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A9125C2980C009AA50C /* UIFont.swift */; };
|
||||
2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198642261BF09500F0B013 /* SearchResultItem.swift */; };
|
||||
2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198648261C0B8500F0B013 /* SearchResultSection.swift */; };
|
||||
2D198655261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198654261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift */; };
|
||||
2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */; };
|
||||
2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7F25F5F45E00143C56 /* UIImage.swift */; };
|
||||
2D206B8625F5FB0900143C56 /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8525F5FB0900143C56 /* Double.swift */; };
|
||||
|
@ -141,8 +140,7 @@
|
|||
2DF75BA725D10E1000694EC8 /* APIService+Favorite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */; };
|
||||
2DF75BB925D1474100694EC8 /* ManagedObjectObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BB825D1474100694EC8 /* ManagedObjectObserver.swift */; };
|
||||
2DF75BC725D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */; };
|
||||
2DFAD5272616F9D300F9EE7C /* SearchViewController+Searching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */; };
|
||||
2DFAD5372617010500F9EE7C /* SearchingTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */; };
|
||||
2DFAD5372617010500F9EE7C /* SearchResultTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFAD5362617010500F9EE7C /* SearchResultTableViewCell.swift */; };
|
||||
5B24BBDA262DB14800A9381B /* ReportViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B24BBD7262DB14800A9381B /* ReportViewModel.swift */; };
|
||||
5B24BBDB262DB14800A9381B /* ReportViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B24BBD8262DB14800A9381B /* ReportViewModel+Diffable.swift */; };
|
||||
5B24BBE2262DB19100A9381B /* APIService+Report.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B24BBE1262DB19100A9381B /* APIService+Report.swift */; };
|
||||
|
@ -241,7 +239,6 @@
|
|||
DB427DED25BAA00100D1B89D /* MastodonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DEC25BAA00100D1B89D /* MastodonTests.swift */; };
|
||||
DB427DF825BAA00100D1B89D /* MastodonUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DF725BAA00100D1B89D /* MastodonUITests.swift */; };
|
||||
DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB44384E25E8C1FA008912A2 /* CALayer.swift */; };
|
||||
DB443CD22694326A00159B29 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = DB443CD0269415D200159B29 /* Localizable.stringsdict */; };
|
||||
DB443CD42694627B00159B29 /* AppearanceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB443CD32694627B00159B29 /* AppearanceView.swift */; };
|
||||
DB44767B260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB44767A260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift */; };
|
||||
DB447681260B3ED600B66B82 /* CustomEmojiPickerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */; };
|
||||
|
@ -272,6 +269,17 @@
|
|||
DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */; };
|
||||
DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */; };
|
||||
DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */; };
|
||||
DB4F0963269ED06300D62E92 /* SearchResultViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F0962269ED06300D62E92 /* SearchResultViewController.swift */; };
|
||||
DB4F0966269ED52200D62E92 /* SearchResultViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F0965269ED52200D62E92 /* SearchResultViewModel.swift */; };
|
||||
DB4F0968269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F0967269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift */; };
|
||||
DB4F096A269EDAD200D62E92 /* SearchResultViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F0969269EDAD200D62E92 /* SearchResultViewModel+State.swift */; };
|
||||
DB4F096C269EFA2000D62E92 /* SearchResultViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F096B269EFA2000D62E92 /* SearchResultViewController+StatusProvider.swift */; };
|
||||
DB4F097526A037F500D62E92 /* SearchHistoryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F097426A037F500D62E92 /* SearchHistoryViewModel.swift */; };
|
||||
DB4F097B26A039FF00D62E92 /* SearchHistorySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F097A26A039FF00D62E92 /* SearchHistorySection.swift */; };
|
||||
DB4F097D26A03A5B00D62E92 /* SearchHistoryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F097C26A03A5B00D62E92 /* SearchHistoryItem.swift */; };
|
||||
DB4F097F26A03DA600D62E92 /* SearchHistoryFetchedResultController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F097E26A03DA600D62E92 /* SearchHistoryFetchedResultController.swift */; };
|
||||
DB4FFC2B269EC39600D62E92 /* SearchToSearchDetailViewControllerAnimatedTransitioning.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4FFC29269EC39600D62E92 /* SearchToSearchDetailViewControllerAnimatedTransitioning.swift */; };
|
||||
DB4FFC2C269EC39600D62E92 /* SearchTransitionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4FFC2A269EC39600D62E92 /* SearchTransitionController.swift */; };
|
||||
DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */; };
|
||||
DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */; };
|
||||
DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */; };
|
||||
|
@ -279,6 +287,8 @@
|
|||
DB51D173262832380062B7A1 /* BlurHashEncode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB51D171262832380062B7A1 /* BlurHashEncode.swift */; };
|
||||
DB52D33A26839DD800D43133 /* ImageTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB52D33926839DD800D43133 /* ImageTask.swift */; };
|
||||
DB55D33025FB630A0002F825 /* TwitterTextEditor+String.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB55D32F25FB630A0002F825 /* TwitterTextEditor+String.swift */; };
|
||||
DB564BD0269F2F83001E39A7 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = DB564BCE269F2F83001E39A7 /* Localizable.stringsdict */; };
|
||||
DB564BD3269F3B35001E39A7 /* StatusFilterService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB564BD2269F3B35001E39A7 /* StatusFilterService.swift */; };
|
||||
DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */; };
|
||||
DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */; };
|
||||
DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */; };
|
||||
|
@ -512,6 +522,9 @@
|
|||
DBE54ABF2636C889004E7C0B /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B23263684C600ACB481 /* UserDefaults.swift */; };
|
||||
DBE54AC62636C89F004E7C0B /* NotificationPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */; };
|
||||
DBE54ACC2636C8FD004E7C0B /* NotificationPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */; };
|
||||
DBF1D24E269DAF5D00C1C08A /* SearchDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF1D24D269DAF5D00C1C08A /* SearchDetailViewController.swift */; };
|
||||
DBF1D251269DB01200C1C08A /* SearchHistoryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF1D250269DB01200C1C08A /* SearchHistoryViewController.swift */; };
|
||||
DBF1D257269DBAC600C1C08A /* SearchDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF1D256269DBAC600C1C08A /* SearchDetailViewModel.swift */; };
|
||||
DBF7A0FC26830C33004176A2 /* FPSIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = DBF7A0FB26830C33004176A2 /* FPSIndicator */; };
|
||||
DBF8AE16263293E400C9C23C /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF8AE15263293E400C9C23C /* NotificationService.swift */; };
|
||||
DBF8AE1A263293E400C9C23C /* NotificationService.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = DBF8AE13263293E400C9C23C /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
|
@ -653,7 +666,6 @@
|
|||
2D152A9125C2980C009AA50C /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = "<group>"; };
|
||||
2D198642261BF09500F0B013 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = "<group>"; };
|
||||
2D198648261C0B8500F0B013 /* SearchResultSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultSection.swift; sourceTree = "<group>"; };
|
||||
2D198654261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewModel+LoadOldestState.swift"; sourceTree = "<group>"; };
|
||||
2D206B7125F5D27F00143C56 /* AudioContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContainerView.swift; sourceTree = "<group>"; };
|
||||
2D206B7F25F5F45E00143C56 /* UIImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.swift; sourceTree = "<group>"; };
|
||||
2D206B8525F5FB0900143C56 /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.swift; sourceTree = "<group>"; };
|
||||
|
@ -753,8 +765,7 @@
|
|||
2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Favorite.swift"; sourceTree = "<group>"; };
|
||||
2DF75BB825D1474100694EC8 /* ManagedObjectObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedObjectObserver.swift; sourceTree = "<group>"; };
|
||||
2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedObjectContextObjectsDidChange.swift; sourceTree = "<group>"; };
|
||||
2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewController+Searching.swift"; sourceTree = "<group>"; };
|
||||
2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchingTableViewCell.swift; sourceTree = "<group>"; };
|
||||
2DFAD5362617010500F9EE7C /* SearchResultTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultTableViewCell.swift; sourceTree = "<group>"; };
|
||||
2E1F6A67FDF9771D3E064FDC /* Pods-Mastodon.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
374AA339A20E0FAC75BCDA6D /* Pods_NotificationService.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_NotificationService.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
3B7FD8F28DDA8FBCE5562B78 /* Pods-NotificationService.asdk - debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationService.asdk - debug.xcconfig"; path = "Target Support Files/Pods-NotificationService/Pods-NotificationService.asdk - debug.xcconfig"; sourceTree = "<group>"; };
|
||||
|
@ -879,8 +890,6 @@
|
|||
DB427DF725BAA00100D1B89D /* MastodonUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonUITests.swift; sourceTree = "<group>"; };
|
||||
DB427DF925BAA00100D1B89D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
DB44384E25E8C1FA008912A2 /* CALayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CALayer.swift; sourceTree = "<group>"; };
|
||||
DB443CCF269415D200159B29 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
|
||||
DB443CD1269415D800159B29 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ar; path = ar.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
|
||||
DB443CD32694627B00159B29 /* AppearanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceView.swift; sourceTree = "<group>"; };
|
||||
DB44767A260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerInputView.swift; sourceTree = "<group>"; };
|
||||
DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerSection.swift; sourceTree = "<group>"; };
|
||||
|
@ -911,6 +920,17 @@
|
|||
DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel+LoadState.swift"; sourceTree = "<group>"; };
|
||||
DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CustomEmoji.swift"; sourceTree = "<group>"; };
|
||||
DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PlayerContainerView+MediaTypeIndicotorView.swift"; sourceTree = "<group>"; };
|
||||
DB4F0962269ED06300D62E92 /* SearchResultViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultViewController.swift; sourceTree = "<group>"; };
|
||||
DB4F0965269ED52200D62E92 /* SearchResultViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultViewModel.swift; sourceTree = "<group>"; };
|
||||
DB4F0967269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistoryTableHeaderView.swift; sourceTree = "<group>"; };
|
||||
DB4F0969269EDAD200D62E92 /* SearchResultViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchResultViewModel+State.swift"; sourceTree = "<group>"; };
|
||||
DB4F096B269EFA2000D62E92 /* SearchResultViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchResultViewController+StatusProvider.swift"; sourceTree = "<group>"; };
|
||||
DB4F097426A037F500D62E92 /* SearchHistoryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistoryViewModel.swift; sourceTree = "<group>"; };
|
||||
DB4F097A26A039FF00D62E92 /* SearchHistorySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistorySection.swift; sourceTree = "<group>"; };
|
||||
DB4F097C26A03A5B00D62E92 /* SearchHistoryItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistoryItem.swift; sourceTree = "<group>"; };
|
||||
DB4F097E26A03DA600D62E92 /* SearchHistoryFetchedResultController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistoryFetchedResultController.swift; sourceTree = "<group>"; };
|
||||
DB4FFC29269EC39600D62E92 /* SearchToSearchDetailViewControllerAnimatedTransitioning.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchToSearchDetailViewControllerAnimatedTransitioning.swift; sourceTree = "<group>"; };
|
||||
DB4FFC2A269EC39600D62E92 /* SearchTransitionController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchTransitionController.swift; sourceTree = "<group>"; };
|
||||
DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarBarButtonItem.swift; sourceTree = "<group>"; };
|
||||
DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarConfigurableView.swift; sourceTree = "<group>"; };
|
||||
DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SplashPreference.swift; sourceTree = "<group>"; };
|
||||
|
@ -918,6 +938,9 @@
|
|||
DB51D171262832380062B7A1 /* BlurHashEncode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlurHashEncode.swift; sourceTree = "<group>"; };
|
||||
DB52D33926839DD800D43133 /* ImageTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageTask.swift; sourceTree = "<group>"; };
|
||||
DB55D32F25FB630A0002F825 /* TwitterTextEditor+String.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "TwitterTextEditor+String.swift"; sourceTree = "<group>"; };
|
||||
DB564BCF269F2F83001E39A7 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ar; path = ar.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
|
||||
DB564BD1269F2F8A001E39A7 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
|
||||
DB564BD2269F3B35001E39A7 /* StatusFilterService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusFilterService.swift; sourceTree = "<group>"; };
|
||||
DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+UITableViewDelegate.swift"; sourceTree = "<group>"; };
|
||||
DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewCellHeightCacheableContainer.swift; sourceTree = "<group>"; };
|
||||
DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Poll.swift"; sourceTree = "<group>"; };
|
||||
|
@ -1137,6 +1160,9 @@
|
|||
DBE3CE0C261D767100430CC6 /* FavoriteViewController+Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewController+Provider.swift"; sourceTree = "<group>"; };
|
||||
DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewControllerAspect.swift; sourceTree = "<group>"; };
|
||||
DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPreference.swift; sourceTree = "<group>"; };
|
||||
DBF1D24D269DAF5D00C1C08A /* SearchDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchDetailViewController.swift; sourceTree = "<group>"; };
|
||||
DBF1D250269DB01200C1C08A /* SearchHistoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistoryViewController.swift; sourceTree = "<group>"; };
|
||||
DBF1D256269DBAC600C1C08A /* SearchDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchDetailViewModel.swift; sourceTree = "<group>"; };
|
||||
DBF53F5F25C14E88008AAC7B /* Mastodon.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = Mastodon.xctestplan; path = Mastodon/Mastodon.xctestplan; sourceTree = "<group>"; };
|
||||
DBF53F6025C14E9D008AAC7B /* MastodonSDK.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MastodonSDK.xctestplan; sourceTree = "<group>"; };
|
||||
DBF8AE13263293E400C9C23C /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
|
@ -1491,6 +1517,7 @@
|
|||
DB297B1A2679FAE200704C90 /* PlaceholderImageCacheService.swift */,
|
||||
DBAEDE5B267A058D00D25FF5 /* BlurhashImageCacheService.swift */,
|
||||
DBAEDE60267B342D00D25FF5 /* StatusContentCacheService.swift */,
|
||||
DB564BD2269F3B35001E39A7 /* StatusFilterService.swift */,
|
||||
);
|
||||
path = Service;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1552,22 +1579,12 @@
|
|||
2D76319D25C151F600929FB9 /* Section */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2D76319E25C1521200929FB9 /* StatusSection.swift */,
|
||||
DB4481C525EE2ADA00BEFB67 /* PollSection.swift */,
|
||||
DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */,
|
||||
DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */,
|
||||
2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */,
|
||||
2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */,
|
||||
DB4F097926A039C400D62E92 /* Status */,
|
||||
DB4F097826A039B400D62E92 /* Onboarding */,
|
||||
DB4F097726A039A200D62E92 /* Search */,
|
||||
DB4F097626A0398000D62E92 /* Compose */,
|
||||
2D4AD8A126316CD200613EFC /* SelectedAccountSection.swift */,
|
||||
2D35237926256D920031AF25 /* NotificationSection.swift */,
|
||||
2D198648261C0B8500F0B013 /* SearchResultSection.swift */,
|
||||
DB66729525F9F91600D60309 /* ComposeStatusSection.swift */,
|
||||
DB36679E268ABAF20027D07F /* ComposeStatusAttachmentSection.swift */,
|
||||
DB3667A5268AE2620027D07F /* ComposeStatusPollSection.swift */,
|
||||
DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */,
|
||||
DB6D9F7C26358ED4008423CD /* SettingsSection.swift */,
|
||||
5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */,
|
||||
DBBF1DC82652538500E5B703 /* AutoCompleteSection.swift */,
|
||||
DBA94433265CBB5300C537E1 /* ProfileFieldSection.swift */,
|
||||
);
|
||||
path = Section;
|
||||
|
@ -1622,6 +1639,7 @@
|
|||
children = (
|
||||
2D7631B225C159F700929FB9 /* Item.swift */,
|
||||
2D198642261BF09500F0B013 /* SearchResultItem.swift */,
|
||||
DB4F097C26A03A5B00D62E92 /* SearchHistoryItem.swift */,
|
||||
2D4AD8A726316D3500613EFC /* SelectedAccountItem.swift */,
|
||||
2D7867182625B77500211898 /* NotificationItem.swift */,
|
||||
DB4481CB25EE2AFE00BEFB67 /* PollItem.swift */,
|
||||
|
@ -1685,7 +1703,7 @@
|
|||
2DFAD5212616F8E300F9EE7C /* TableViewCell */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */,
|
||||
2DFAD5362617010500F9EE7C /* SearchResultTableViewCell.swift */,
|
||||
);
|
||||
path = TableViewCell;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1842,7 +1860,7 @@
|
|||
164F0EBB267D4FE400249499 /* BoopSound.caf */,
|
||||
DB427DDE25BAA00100D1B89D /* Assets.xcassets */,
|
||||
DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */,
|
||||
DB443CD0269415D200159B29 /* Localizable.stringsdict */,
|
||||
DB564BCE269F2F83001E39A7 /* Localizable.stringsdict */,
|
||||
DB3D100F25BAA75E00EAA174 /* Localizable.strings */,
|
||||
DB2B3ABE25E37E15007045F9 /* InfoPlist.strings */,
|
||||
);
|
||||
|
@ -1988,6 +2006,77 @@
|
|||
path = EmojiService;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB4F0964269ED06700D62E92 /* SearchResult */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DB4F0962269ED06300D62E92 /* SearchResultViewController.swift */,
|
||||
DB4F096B269EFA2000D62E92 /* SearchResultViewController+StatusProvider.swift */,
|
||||
DB4F0965269ED52200D62E92 /* SearchResultViewModel.swift */,
|
||||
DB4F0969269EDAD200D62E92 /* SearchResultViewModel+State.swift */,
|
||||
);
|
||||
path = SearchResult;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB4F097626A0398000D62E92 /* Compose */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DB66729525F9F91600D60309 /* ComposeStatusSection.swift */,
|
||||
DB36679E268ABAF20027D07F /* ComposeStatusAttachmentSection.swift */,
|
||||
DB3667A5268AE2620027D07F /* ComposeStatusPollSection.swift */,
|
||||
DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */,
|
||||
DBBF1DC82652538500E5B703 /* AutoCompleteSection.swift */,
|
||||
);
|
||||
path = Compose;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB4F097726A039A200D62E92 /* Search */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */,
|
||||
2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */,
|
||||
2D198648261C0B8500F0B013 /* SearchResultSection.swift */,
|
||||
DB4F097A26A039FF00D62E92 /* SearchHistorySection.swift */,
|
||||
);
|
||||
path = Search;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB4F097826A039B400D62E92 /* Onboarding */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */,
|
||||
DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */,
|
||||
);
|
||||
path = Onboarding;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB4F097926A039C400D62E92 /* Status */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2D76319E25C1521200929FB9 /* StatusSection.swift */,
|
||||
DB4481C525EE2ADA00BEFB67 /* PollSection.swift */,
|
||||
2D35237926256D920031AF25 /* NotificationSection.swift */,
|
||||
5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */,
|
||||
);
|
||||
path = Status;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB4F098026A0475500D62E92 /* View */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DB4F0967269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift */,
|
||||
);
|
||||
path = View;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB4FFC2D269EC39C00D62E92 /* Search */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DB4FFC2A269EC39600D62E92 /* SearchTransitionController.swift */,
|
||||
DB4FFC29269EC39600D62E92 /* SearchToSearchDetailViewControllerAnimatedTransitioning.swift */,
|
||||
);
|
||||
path = Search;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB5086CB25CC0DB400C2C187 /* Preference */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -2041,6 +2130,7 @@
|
|||
children = (
|
||||
DB6180E226391A4C0018D199 /* ViewControllerAnimatedTransitioning.swift */,
|
||||
DB6180E726391B580018D199 /* MediaPreview */,
|
||||
DB4FFC2D269EC39C00D62E92 /* Search */,
|
||||
);
|
||||
path = Transition;
|
||||
sourceTree = "<group>";
|
||||
|
@ -2385,15 +2475,8 @@
|
|||
DB9D6BEE25E4F5370051B173 /* Search */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2DFAD5212616F8E300F9EE7C /* TableViewCell */,
|
||||
2DE0FAC62615F5D200CDF649 /* View */,
|
||||
DB9D6BE825E4F5340051B173 /* SearchViewController.swift */,
|
||||
2D34D9CA261489930081BFC0 /* SearchViewController+Recommend.swift */,
|
||||
2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */,
|
||||
5DFC35DE262068D20045711D /* SearchViewController+Follow.swift */,
|
||||
2D6DE3FF26141DF600A63F6A /* SearchViewModel.swift */,
|
||||
2D198654261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift */,
|
||||
2D34D9E026149C550081BFC0 /* CollectionViewCell */,
|
||||
DBF1D253269DB02C00C1C08A /* Search */,
|
||||
DBF1D24F269DAF6100C1C08A /* SearchDetail */,
|
||||
);
|
||||
path = Search;
|
||||
sourceTree = "<group>";
|
||||
|
@ -2620,6 +2703,7 @@
|
|||
DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */,
|
||||
DBA088DE26958164003EB4B2 /* UserFetchedResultsController.swift */,
|
||||
DB6D9F75263587C7008423CD /* SettingFetchedResultController.swift */,
|
||||
DB4F097E26A03DA600D62E92 /* SearchHistoryFetchedResultController.swift */,
|
||||
);
|
||||
path = FetchedResultsController;
|
||||
sourceTree = "<group>";
|
||||
|
@ -2657,6 +2741,41 @@
|
|||
path = Favorite;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DBF1D24F269DAF6100C1C08A /* SearchDetail */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2DFAD5212616F8E300F9EE7C /* TableViewCell */,
|
||||
DB4F0964269ED06700D62E92 /* SearchResult */,
|
||||
DBF1D252269DB01700C1C08A /* SearchHistory */,
|
||||
DBF1D24D269DAF5D00C1C08A /* SearchDetailViewController.swift */,
|
||||
DBF1D256269DBAC600C1C08A /* SearchDetailViewModel.swift */,
|
||||
);
|
||||
path = SearchDetail;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DBF1D252269DB01700C1C08A /* SearchHistory */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DB4F098026A0475500D62E92 /* View */,
|
||||
DBF1D250269DB01200C1C08A /* SearchHistoryViewController.swift */,
|
||||
DB4F097426A037F500D62E92 /* SearchHistoryViewModel.swift */,
|
||||
);
|
||||
path = SearchHistory;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DBF1D253269DB02C00C1C08A /* Search */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2D34D9E026149C550081BFC0 /* CollectionViewCell */,
|
||||
2DE0FAC62615F5D200CDF649 /* View */,
|
||||
DB9D6BE825E4F5340051B173 /* SearchViewController.swift */,
|
||||
2D34D9CA261489930081BFC0 /* SearchViewController+Recommend.swift */,
|
||||
5DFC35DE262068D20045711D /* SearchViewController+Follow.swift */,
|
||||
2D6DE3FF26141DF600A63F6A /* SearchViewModel.swift */,
|
||||
);
|
||||
path = Search;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DBF8AE14263293E400C9C23C /* NotificationService */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -2949,7 +3068,7 @@
|
|||
files = (
|
||||
164F0EBC267D4FE400249499 /* BoopSound.caf in Resources */,
|
||||
DB427DE225BAA00100D1B89D /* LaunchScreen.storyboard in Resources */,
|
||||
DB443CD22694326A00159B29 /* Localizable.stringsdict in Resources */,
|
||||
DB564BD0269F2F83001E39A7 /* Localizable.stringsdict in Resources */,
|
||||
DB3D100D25BAA75E00EAA174 /* Localizable.strings in Resources */,
|
||||
DB427DDF25BAA00100D1B89D /* Assets.xcassets in Resources */,
|
||||
DB427DDD25BAA00100D1B89D /* Main.storyboard in Resources */,
|
||||
|
@ -3188,6 +3307,7 @@
|
|||
DBA94440265D137600C537E1 /* Mastodon+Entity+Field.swift in Sources */,
|
||||
DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */,
|
||||
DBBF1DC7265251D400E5B703 /* AutoCompleteViewModel+State.swift in Sources */,
|
||||
DB4FFC2B269EC39600D62E92 /* SearchToSearchDetailViewControllerAnimatedTransitioning.swift in Sources */,
|
||||
DBCC3B9526157E6E0045B23D /* APIService+Relationship.swift in Sources */,
|
||||
2D7631B325C159F700929FB9 /* Item.swift in Sources */,
|
||||
5DF1054125F886D400D6C0D4 /* VideoPlaybackService.swift in Sources */,
|
||||
|
@ -3198,7 +3318,7 @@
|
|||
DB66729625F9F91600D60309 /* ComposeStatusSection.swift in Sources */,
|
||||
DB482A3F261331E8008AE74C /* UserTimelineViewModel+State.swift in Sources */,
|
||||
2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */,
|
||||
2DFAD5372617010500F9EE7C /* SearchingTableViewCell.swift in Sources */,
|
||||
2DFAD5372617010500F9EE7C /* SearchResultTableViewCell.swift in Sources */,
|
||||
DB447681260B3ED600B66B82 /* CustomEmojiPickerSection.swift in Sources */,
|
||||
5BB04FEF262F0DCB0043BFF6 /* ReportViewModel+Data.swift in Sources */,
|
||||
5B8E055826319E47006E3C53 /* ReportFooterView.swift in Sources */,
|
||||
|
@ -3216,6 +3336,7 @@
|
|||
DBD376AA2692EA4F007FEC24 /* Theme.swift in Sources */,
|
||||
0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */,
|
||||
DB443CD42694627B00159B29 /* AppearanceView.swift in Sources */,
|
||||
DBF1D24E269DAF5D00C1C08A /* SearchDetailViewController.swift in Sources */,
|
||||
DB8AF53025C13561002E6C99 /* AppContext.swift in Sources */,
|
||||
DB92CF7225E7BB98002C1017 /* PollOptionTableViewCell.swift in Sources */,
|
||||
DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */,
|
||||
|
@ -3245,12 +3366,13 @@
|
|||
2D607AD826242FC500B70763 /* NotificationViewModel.swift in Sources */,
|
||||
DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */,
|
||||
DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */,
|
||||
DB564BD3269F3B35001E39A7 /* StatusFilterService.swift in Sources */,
|
||||
DB71FD4625F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift in Sources */,
|
||||
DB297B1B2679FAE200704C90 /* PlaceholderImageCacheService.swift in Sources */,
|
||||
2D8FCA082637EABB00137F46 /* APIService+FollowRequest.swift in Sources */,
|
||||
2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */,
|
||||
2DE0FAC82615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift in Sources */,
|
||||
2DFAD5272616F9D300F9EE7C /* SearchViewController+Searching.swift in Sources */,
|
||||
DBF1D251269DB01200C1C08A /* SearchHistoryViewController.swift in Sources */,
|
||||
2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */,
|
||||
DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */,
|
||||
DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */,
|
||||
|
@ -3318,6 +3440,7 @@
|
|||
2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */,
|
||||
0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */,
|
||||
DB6D9F9726367249008423CD /* SettingsViewController.swift in Sources */,
|
||||
DB4F097F26A03DA600D62E92 /* SearchHistoryFetchedResultController.swift in Sources */,
|
||||
DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */,
|
||||
DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */,
|
||||
DBE3CE0D261D767100430CC6 /* FavoriteViewController+Provider.swift in Sources */,
|
||||
|
@ -3360,6 +3483,7 @@
|
|||
5B24BBDB262DB14800A9381B /* ReportViewModel+Diffable.swift in Sources */,
|
||||
DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */,
|
||||
2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */,
|
||||
DB4F0968269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift in Sources */,
|
||||
0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */,
|
||||
5DA732CC2629CEF500A92342 /* UIView+Remove.swift in Sources */,
|
||||
DBAEDE5C267A058D00D25FF5 /* BlurhashImageCacheService.swift in Sources */,
|
||||
|
@ -3411,11 +3535,13 @@
|
|||
DB6F5E2F264E5518009108F4 /* MastodonRegex.swift in Sources */,
|
||||
DB023295267F0AB800031745 /* ASMetaEditableTextNode.swift in Sources */,
|
||||
2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */,
|
||||
DB4F096C269EFA2000D62E92 /* SearchResultViewController+StatusProvider.swift in Sources */,
|
||||
DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */,
|
||||
DB6D9F7D26358ED4008423CD /* SettingsSection.swift in Sources */,
|
||||
DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */,
|
||||
DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */,
|
||||
2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */,
|
||||
DBF1D257269DBAC600C1C08A /* SearchDetailViewModel.swift in Sources */,
|
||||
DB03F7F52689B782007B274C /* ComposeTableView.swift in Sources */,
|
||||
2D24E11D2626D8B100A59D4F /* NotificationStatusTableViewCell.swift in Sources */,
|
||||
DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */,
|
||||
|
@ -3435,6 +3561,7 @@
|
|||
DB6180E626391B550018D199 /* MediaPreviewTransitionController.swift in Sources */,
|
||||
5BB04FDB262EA3070043BFF6 /* ReportHeaderView.swift in Sources */,
|
||||
DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */,
|
||||
DB4F0963269ED06300D62E92 /* SearchResultViewController.swift in Sources */,
|
||||
DBBF1DC5265251C300E5B703 /* AutoCompleteViewModel+Diffable.swift in Sources */,
|
||||
DB68A04A25E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift in Sources */,
|
||||
0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */,
|
||||
|
@ -3445,6 +3572,7 @@
|
|||
DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */,
|
||||
DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */,
|
||||
2DAC9E46262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift in Sources */,
|
||||
DB4FFC2C269EC39600D62E92 /* SearchTransitionController.swift in Sources */,
|
||||
DBA5E7A9263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift in Sources */,
|
||||
DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */,
|
||||
2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */,
|
||||
|
@ -3453,6 +3581,7 @@
|
|||
DBA94438265CBD4D00C537E1 /* ProfileHeaderViewModel+Diffable.swift in Sources */,
|
||||
0F20220D26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift in Sources */,
|
||||
DBCC3B8F26148F7B0045B23D /* CachedProfileViewModel.swift in Sources */,
|
||||
DB4F097526A037F500D62E92 /* SearchHistoryViewModel.swift in Sources */,
|
||||
DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */,
|
||||
DB6180F826391D660018D199 /* MediaPreviewingViewController.swift in Sources */,
|
||||
DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */,
|
||||
|
@ -3480,6 +3609,7 @@
|
|||
DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */,
|
||||
DBD376AC2692ECDB007FEC24 /* ThemePreference.swift in Sources */,
|
||||
DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */,
|
||||
DB4F097D26A03A5B00D62E92 /* SearchHistoryItem.swift in Sources */,
|
||||
DB68046C2636DC9E00430867 /* MastodonNotification.swift in Sources */,
|
||||
DBAE3F9E2616E308004B8251 /* APIService+Mute.swift in Sources */,
|
||||
DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */,
|
||||
|
@ -3525,11 +3655,11 @@
|
|||
DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */,
|
||||
2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */,
|
||||
DBD376B2269302A4007FEC24 /* UITableViewCell.swift in Sources */,
|
||||
DB4F0966269ED52200D62E92 /* SearchResultViewModel.swift in Sources */,
|
||||
DBAC6499267DF2C4007FE9FD /* TimelineBottomLoaderNode.swift in Sources */,
|
||||
2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */,
|
||||
DBBF1DBF2652401B00E5B703 /* AutoCompleteViewModel.swift in Sources */,
|
||||
DB6180FA26391F2E0018D199 /* MediaPreviewViewModel.swift in Sources */,
|
||||
2D198655261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift in Sources */,
|
||||
0F202227261411BB000C64BF /* HashtagTimelineViewController+Provider.swift in Sources */,
|
||||
2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */,
|
||||
2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */,
|
||||
|
@ -3549,6 +3679,7 @@
|
|||
DB87D4512609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift in Sources */,
|
||||
DB9A489026035963008B817C /* APIService+Media.swift in Sources */,
|
||||
2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */,
|
||||
DB4F097B26A039FF00D62E92 /* SearchHistorySection.swift in Sources */,
|
||||
DBB525302611EBF3002F1F29 /* ProfilePagingViewModel.swift in Sources */,
|
||||
DBCBCC0B2680B03F000F5B51 /* AsyncHomeTimelineViewModel+LoadOldestState.swift in Sources */,
|
||||
2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */,
|
||||
|
@ -3573,6 +3704,7 @@
|
|||
DB1EE7B2267F9525000CC337 /* StatusProvider+StatusNodeDelegate.swift in Sources */,
|
||||
DB03F7F026899097007B274C /* ComposeStatusContentTableViewCell.swift in Sources */,
|
||||
5B24BBE2262DB19100A9381B /* APIService+Report.swift in Sources */,
|
||||
DB4F096A269EDAD200D62E92 /* SearchResultViewModel+State.swift in Sources */,
|
||||
5BB04FF5262F0E6D0043BFF6 /* ReportSection.swift in Sources */,
|
||||
DBA94436265CBB7400C537E1 /* ProfileFieldItem.swift in Sources */,
|
||||
DB938F25262438D600E5B6C1 /* ThreadViewController+Provider.swift in Sources */,
|
||||
|
@ -3770,15 +3902,14 @@
|
|||
name = LaunchScreen.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB443CD0269415D200159B29 /* Localizable.stringsdict */ = {
|
||||
DB564BCE269F2F83001E39A7 /* Localizable.stringsdict */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
DB443CCF269415D200159B29 /* en */,
|
||||
DB443CD1269415D800159B29 /* ar */,
|
||||
DB564BCF269F2F83001E39A7 /* ar */,
|
||||
DB564BD1269F2F8A001E39A7 /* en */,
|
||||
);
|
||||
name = Localizable.stringsdict;
|
||||
path = /Users/mainasuk/Developer/Mastodon/Mastodon/Resources;
|
||||
sourceTree = "<absolute>";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
|
@ -3910,7 +4041,7 @@
|
|||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 37;
|
||||
CURRENT_PROJECT_VERSION = 40;
|
||||
DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets";
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
INFOPLIST_FILE = Mastodon/Info.plist;
|
||||
|
@ -3918,13 +4049,13 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.8.11;
|
||||
MARKETING_VERSION = 0.9.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
|
@ -3937,7 +4068,7 @@
|
|||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 37;
|
||||
CURRENT_PROJECT_VERSION = 40;
|
||||
DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets";
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
INFOPLIST_FILE = Mastodon/Info.plist;
|
||||
|
@ -3945,12 +4076,12 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.8.11;
|
||||
MARKETING_VERSION = 0.9.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
|
@ -4265,7 +4396,7 @@
|
|||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 37;
|
||||
CURRENT_PROJECT_VERSION = 40;
|
||||
DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets";
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
INFOPLIST_FILE = Mastodon/Info.plist;
|
||||
|
@ -4273,13 +4404,13 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.8.11;
|
||||
MARKETING_VERSION = 0.9.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = "ASDK - Release";
|
||||
};
|
||||
|
@ -4379,7 +4510,7 @@
|
|||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 37;
|
||||
CURRENT_PROJECT_VERSION = 40;
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
INFOPLIST_FILE = NotificationService/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
|
@ -4387,12 +4518,12 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.8.11;
|
||||
MARKETING_VERSION = 0.9.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = "ASDK - Release";
|
||||
};
|
||||
|
@ -4498,7 +4629,7 @@
|
|||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 37;
|
||||
CURRENT_PROJECT_VERSION = 40;
|
||||
DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets";
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
INFOPLIST_FILE = Mastodon/Info.plist;
|
||||
|
@ -4506,13 +4637,13 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.8.11;
|
||||
MARKETING_VERSION = 0.9.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = "ASDK - Debug";
|
||||
};
|
||||
|
@ -4612,7 +4743,7 @@
|
|||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 37;
|
||||
CURRENT_PROJECT_VERSION = 40;
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
INFOPLIST_FILE = NotificationService/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
|
@ -4620,12 +4751,12 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.8.11;
|
||||
MARKETING_VERSION = 0.9.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = "ASDK - Debug";
|
||||
};
|
||||
|
@ -4666,7 +4797,7 @@
|
|||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 37;
|
||||
CURRENT_PROJECT_VERSION = 40;
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
INFOPLIST_FILE = NotificationService/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
|
@ -4674,12 +4805,12 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.8.11;
|
||||
MARKETING_VERSION = 0.9.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
|
@ -4689,7 +4820,7 @@
|
|||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 37;
|
||||
CURRENT_PROJECT_VERSION = 40;
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
INFOPLIST_FILE = NotificationService/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
|
@ -4697,12 +4828,12 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.8.11;
|
||||
MARKETING_VERSION = 0.9.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
<key>CoreDataStack.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>20</integer>
|
||||
<integer>21</integer>
|
||||
</dict>
|
||||
<key>Mastodon - ASDK.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
|
@ -37,7 +37,7 @@
|
|||
<key>NotificationService.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>21</integer>
|
||||
<integer>20</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
|
|
|
@ -52,7 +52,10 @@ extension SceneCoordinator {
|
|||
// ASDK
|
||||
case asyncHome
|
||||
#endif
|
||||
|
||||
|
||||
// search
|
||||
case searchDetail(viewModel: SearchDetailViewModel)
|
||||
|
||||
// compose
|
||||
case compose(viewModel: ComposeViewModel)
|
||||
|
||||
|
@ -254,6 +257,10 @@ private extension SceneCoordinator {
|
|||
let _viewController = AsyncHomeTimelineViewController()
|
||||
viewController = _viewController
|
||||
#endif
|
||||
case .searchDetail(let viewModel):
|
||||
let _viewController = SearchDetailViewController()
|
||||
_viewController.viewModel = viewModel
|
||||
viewController = _viewController
|
||||
case .compose(let viewModel):
|
||||
let _viewController = ComposeViewController()
|
||||
_viewController.viewModel = viewModel
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
//
|
||||
// SearchHistoryFetchedResultController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-7-15.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
|
||||
final class SearchHistoryFetchedResultController: NSObject {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
let fetchedResultsController: NSFetchedResultsController<SearchHistory>
|
||||
|
||||
// output
|
||||
let objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([])
|
||||
|
||||
init(managedObjectContext: NSManagedObjectContext) {
|
||||
self.fetchedResultsController = {
|
||||
let fetchRequest = SearchHistory.sortedFetchRequest
|
||||
fetchRequest.returnsObjectsAsFaults = false
|
||||
fetchRequest.fetchBatchSize = 20
|
||||
let controller = NSFetchedResultsController(
|
||||
fetchRequest: fetchRequest,
|
||||
managedObjectContext: managedObjectContext,
|
||||
sectionNameKeyPath: nil,
|
||||
cacheName: nil
|
||||
)
|
||||
|
||||
return controller
|
||||
}()
|
||||
super.init()
|
||||
|
||||
fetchedResultsController.delegate = self
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - NSFetchedResultsControllerDelegate
|
||||
extension SearchHistoryFetchedResultController: NSFetchedResultsControllerDelegate {
|
||||
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
|
||||
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
|
||||
let objects = fetchedResultsController.fetchedObjects ?? []
|
||||
self.objectIDs.value = objects.map { $0.objectID }
|
||||
}
|
||||
}
|
|
@ -31,6 +31,7 @@ enum Item {
|
|||
case publicMiddleLoader(statusID: String)
|
||||
case topLoader
|
||||
case bottomLoader
|
||||
case emptyBottomLoader
|
||||
|
||||
case emptyStateHeader(attribute: EmptyStateHeaderAttribute)
|
||||
|
||||
|
@ -98,6 +99,7 @@ extension Item {
|
|||
super.init(isSeparatorLineHidden: isSeparatorLineHidden)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Item: Equatable {
|
||||
|
@ -123,6 +125,8 @@ extension Item: Equatable {
|
|||
return true
|
||||
case (.bottomLoader, .bottomLoader):
|
||||
return true
|
||||
case (.emptyBottomLoader, .emptyBottomLoader):
|
||||
return true
|
||||
case (.emptyStateHeader(let attributeLeft), .emptyStateHeader(let attributeRight)):
|
||||
return attributeLeft == attributeRight
|
||||
case (.reportStatus(let objectIDLeft, _), .reportStatus(let objectIDRight, _)):
|
||||
|
@ -158,6 +162,8 @@ extension Item: Hashable {
|
|||
hasher.combine(String(describing: Item.topLoader.self))
|
||||
case .bottomLoader:
|
||||
hasher.combine(String(describing: Item.bottomLoader.self))
|
||||
case .emptyBottomLoader:
|
||||
hasher.combine(String(describing: Item.emptyBottomLoader.self))
|
||||
case .emptyStateHeader(let attribute):
|
||||
hasher.combine(attribute)
|
||||
case .reportStatus(let objectID, _):
|
||||
|
@ -167,3 +173,26 @@ extension Item: Hashable {
|
|||
}
|
||||
|
||||
extension Item: Differentiable { }
|
||||
|
||||
extension Item {
|
||||
var statusObjectItem: StatusObjectItem? {
|
||||
switch self {
|
||||
case .homeTimelineIndex(let objectID, _):
|
||||
return .homeTimelineIndex(objectID: objectID)
|
||||
case .root(let objectID, _),
|
||||
.reply(let objectID, _),
|
||||
.leaf(let objectID, _),
|
||||
.status(let objectID, _),
|
||||
.reportStatus(let objectID, _):
|
||||
return .status(objectID: objectID)
|
||||
case .leafBottomLoader,
|
||||
.homeMiddleLoader,
|
||||
.publicMiddleLoader,
|
||||
.topLoader,
|
||||
.bottomLoader,
|
||||
.emptyBottomLoader,
|
||||
.emptyStateHeader:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -37,3 +37,14 @@ extension NotificationItem: Hashable {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension NotificationItem {
|
||||
var statusObjectItem: StatusObjectItem? {
|
||||
switch self {
|
||||
case .notification(let objectID, _):
|
||||
return .mastodonNotification(objectID: objectID)
|
||||
case .bottomLoader:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
//
|
||||
// SearchHistoryItem.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-7-15.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
enum SearchHistoryItem {
|
||||
case account(objectID: NSManagedObjectID)
|
||||
case hashtag(objectID: NSManagedObjectID)
|
||||
case status(objectID: NSManagedObjectID, attribute: Item.StatusAttribute)
|
||||
}
|
||||
|
||||
extension SearchHistoryItem: Hashable {
|
||||
static func == (lhs: SearchHistoryItem, rhs: SearchHistoryItem) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.account(let objectIDLeft), account(let objectIDRight)):
|
||||
return objectIDLeft == objectIDRight
|
||||
case (.hashtag(let objectIDLeft), hashtag(let objectIDRight)):
|
||||
return objectIDLeft == objectIDRight
|
||||
case (.status(let objectIDLeft, _), status(let objectIDRight, _)):
|
||||
return objectIDLeft == objectIDRight
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
switch self {
|
||||
case .account(let objectID):
|
||||
hasher.combine(objectID)
|
||||
case .hashtag(let objectID):
|
||||
hasher.combine(objectID)
|
||||
case .status(let objectID, _):
|
||||
hasher.combine(objectID)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -11,14 +11,29 @@ import MastodonSDK
|
|||
|
||||
enum SearchResultItem {
|
||||
case hashtag(tag: Mastodon.Entity.Tag)
|
||||
|
||||
case account(account: Mastodon.Entity.Account)
|
||||
case status(statusObjectID: NSManagedObjectID, attribute: Item.StatusAttribute)
|
||||
case bottomLoader(attribute: BottomLoaderAttribute)
|
||||
}
|
||||
|
||||
case accountObjectID(accountObjectID: NSManagedObjectID)
|
||||
extension SearchResultItem {
|
||||
class BottomLoaderAttribute: Hashable {
|
||||
let id = UUID()
|
||||
|
||||
case hashtagObjectID(hashtagObjectID: NSManagedObjectID)
|
||||
var isNoResult: Bool
|
||||
|
||||
case bottomLoader
|
||||
init(isEmptyResult: Bool) {
|
||||
self.isNoResult = isEmptyResult
|
||||
}
|
||||
|
||||
static func == (lhs: SearchResultItem.BottomLoaderAttribute, rhs: SearchResultItem.BottomLoaderAttribute) -> Bool {
|
||||
return lhs.id == rhs.id
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SearchResultItem: Equatable {
|
||||
|
@ -28,12 +43,10 @@ extension SearchResultItem: Equatable {
|
|||
return tagLeft == tagRight
|
||||
case (.account(let accountLeft), .account(let accountRight)):
|
||||
return accountLeft == accountRight
|
||||
case (.bottomLoader, .bottomLoader):
|
||||
return true
|
||||
case (.accountObjectID(let idLeft),.accountObjectID(let idRight)):
|
||||
return idLeft == idRight
|
||||
case (.hashtagObjectID(let idLeft),.hashtagObjectID(let idRight)):
|
||||
case (.status(let idLeft, _), .status(let idRight, _)):
|
||||
return idLeft == idRight
|
||||
case (.bottomLoader(let attributeLeft), .bottomLoader(let attributeRight)):
|
||||
return attributeLeft == attributeRight
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
@ -44,15 +57,38 @@ extension SearchResultItem: Hashable {
|
|||
func hash(into hasher: inout Hasher) {
|
||||
switch self {
|
||||
case .account(let account):
|
||||
hasher.combine(account)
|
||||
hasher.combine(String(describing: SearchResultItem.account.self))
|
||||
hasher.combine(account.id)
|
||||
case .hashtag(let tag):
|
||||
hasher.combine(tag)
|
||||
case .accountObjectID(let id):
|
||||
hasher.combine(String(describing: SearchResultItem.hashtag.self))
|
||||
hasher.combine(tag.name)
|
||||
case .status(let id, _):
|
||||
hasher.combine(id)
|
||||
case .hashtagObjectID(let id):
|
||||
hasher.combine(id)
|
||||
case .bottomLoader:
|
||||
hasher.combine(String(describing: SearchResultItem.bottomLoader.self))
|
||||
case .bottomLoader(let attribute):
|
||||
hasher.combine(attribute)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SearchResultItem {
|
||||
var sortKey: String? {
|
||||
switch self {
|
||||
case .account(let account): return account.displayName.lowercased()
|
||||
case .hashtag(let hashtag): return hashtag.name.lowercased()
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SearchResultItem {
|
||||
var statusObjectItem: StatusObjectItem? {
|
||||
switch self {
|
||||
case .status(let objectID, _):
|
||||
return .status(objectID: objectID)
|
||||
case .hashtag,
|
||||
.account,
|
||||
.bottomLoader:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,9 +10,9 @@ import CoreData
|
|||
|
||||
enum SettingsItem: Hashable {
|
||||
case appearance(settingObjectID: NSManagedObjectID)
|
||||
case appearanceDarkMode(settingObjectID: NSManagedObjectID)
|
||||
case appearanceDisableAvatarAnimation(settingObjectID: NSManagedObjectID)
|
||||
case notification(settingObjectID: NSManagedObjectID, switchMode: NotificationSwitchMode)
|
||||
case preferenceDarkMode(settingObjectID: NSManagedObjectID)
|
||||
case preferenceDisableAvatarAnimation(settingObjectID: NSManagedObjectID)
|
||||
case preferenceUsingDefaultBrowser(settingObjectID: NSManagedObjectID)
|
||||
case boringZone(item: Link)
|
||||
case spicyZone(item: Link)
|
||||
|
@ -43,6 +43,7 @@ extension SettingsItem {
|
|||
}
|
||||
|
||||
enum Link: CaseIterable {
|
||||
case accountSettings
|
||||
case termsOfService
|
||||
case privacyPolicy
|
||||
case clearMediaCache
|
||||
|
@ -50,15 +51,17 @@ extension SettingsItem {
|
|||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .termsOfService: return L10n.Scene.Settings.Section.Boringzone.terms
|
||||
case .privacyPolicy: return L10n.Scene.Settings.Section.Boringzone.privacy
|
||||
case .clearMediaCache: return L10n.Scene.Settings.Section.Spicyzone.clear
|
||||
case .signOut: return L10n.Scene.Settings.Section.Spicyzone.signout
|
||||
case .accountSettings: return L10n.Scene.Settings.Section.BoringZone.accountSettings
|
||||
case .termsOfService: return L10n.Scene.Settings.Section.BoringZone.terms
|
||||
case .privacyPolicy: return L10n.Scene.Settings.Section.BoringZone.privacy
|
||||
case .clearMediaCache: return L10n.Scene.Settings.Section.SpicyZone.clear
|
||||
case .signOut: return L10n.Scene.Settings.Section.SpicyZone.signout
|
||||
}
|
||||
}
|
||||
|
||||
var textColor: UIColor {
|
||||
switch self {
|
||||
case .accountSettings: return Asset.Colors.brandBlue.color
|
||||
case .termsOfService: return Asset.Colors.brandBlue.color
|
||||
case .privacyPolicy: return Asset.Colors.brandBlue.color
|
||||
case .clearMediaCache: return .systemRed
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
//
|
||||
// SearchHistorySection.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-7-15.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import CoreDataStack
|
||||
|
||||
enum SearchHistorySection: Hashable {
|
||||
case main
|
||||
}
|
||||
|
||||
extension SearchHistorySection {
|
||||
static func tableViewDiffableDataSource(
|
||||
for tableView: UITableView,
|
||||
dependency: NeedsDependency
|
||||
) -> UITableViewDiffableDataSource<SearchHistorySection, SearchHistoryItem> {
|
||||
UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in
|
||||
switch item {
|
||||
case .account(let objectID):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchResultTableViewCell.self), for: indexPath) as! SearchResultTableViewCell
|
||||
if let user = try? dependency.context.managedObjectContext.existingObject(with: objectID) as? MastodonUser {
|
||||
cell.config(with: user)
|
||||
}
|
||||
return cell
|
||||
case .hashtag(let objectID):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchResultTableViewCell.self), for: indexPath) as! SearchResultTableViewCell
|
||||
if let hashtag = try? dependency.context.managedObjectContext.existingObject(with: objectID) as? Tag {
|
||||
cell.config(with: hashtag)
|
||||
}
|
||||
return cell
|
||||
case .status:
|
||||
return UITableViewCell()
|
||||
// let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell
|
||||
// if let status = try? dependency.context.managedObjectContext.existingObject(with: statusObjectID) as? Status {
|
||||
// let activeMastodonAuthenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value
|
||||
// let requestUserID = activeMastodonAuthenticationBox?.userID ?? ""
|
||||
// StatusSection.configure(
|
||||
// cell: cell,
|
||||
// tableView: tableView,
|
||||
// timelineContext: .search,
|
||||
// dependency: dependency,
|
||||
// readableLayoutFrame: tableView.readableContentGuide.layoutFrame,
|
||||
// status: status,
|
||||
// requestUserID: requestUserID,
|
||||
// statusItemAttribute: attribute
|
||||
// )
|
||||
// }
|
||||
// cell.delegate = statusTableViewCellDelegate
|
||||
// return cell
|
||||
} // end switch
|
||||
} // end UITableViewDiffableDataSource
|
||||
} // end func
|
||||
}
|
|
@ -0,0 +1,81 @@
|
|||
//
|
||||
// SearchResultSection.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/4/6.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MastodonSDK
|
||||
import UIKit
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
|
||||
enum SearchResultSection: Equatable, Hashable {
|
||||
case main
|
||||
}
|
||||
|
||||
extension SearchResultSection {
|
||||
static func tableViewDiffableDataSource(
|
||||
for tableView: UITableView,
|
||||
dependency: NeedsDependency,
|
||||
statusTableViewCellDelegate: StatusTableViewCellDelegate
|
||||
) -> UITableViewDiffableDataSource<SearchResultSection, SearchResultItem> {
|
||||
UITableViewDiffableDataSource(tableView: tableView) { [
|
||||
weak statusTableViewCellDelegate
|
||||
] tableView, indexPath, item -> UITableViewCell? in
|
||||
switch item {
|
||||
case .account(let account):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchResultTableViewCell.self), for: indexPath) as! SearchResultTableViewCell
|
||||
cell.config(with: account)
|
||||
return cell
|
||||
case .hashtag(let tag):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchResultTableViewCell.self), for: indexPath) as! SearchResultTableViewCell
|
||||
cell.config(with: tag)
|
||||
return cell
|
||||
// case .hashtagObjectID(let hashtagObjectID):
|
||||
// let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell
|
||||
// let tag = dependency.context.managedObjectContext.object(with: hashtagObjectID) as! Tag
|
||||
// cell.config(with: tag)
|
||||
// return cell
|
||||
// case .accountObjectID(let accountObjectID):
|
||||
// let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell
|
||||
// let user = dependency.context.managedObjectContext.object(with: accountObjectID) as! MastodonUser
|
||||
// cell.config(with: user)
|
||||
// return cell
|
||||
case .status(let statusObjectID, let attribute):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell
|
||||
if let status = try? dependency.context.managedObjectContext.existingObject(with: statusObjectID) as? Status {
|
||||
let activeMastodonAuthenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value
|
||||
let requestUserID = activeMastodonAuthenticationBox?.userID ?? ""
|
||||
StatusSection.configure(
|
||||
cell: cell,
|
||||
tableView: tableView,
|
||||
timelineContext: .search,
|
||||
dependency: dependency,
|
||||
readableLayoutFrame: tableView.readableContentGuide.layoutFrame,
|
||||
status: status,
|
||||
requestUserID: requestUserID,
|
||||
statusItemAttribute: attribute
|
||||
)
|
||||
}
|
||||
cell.delegate = statusTableViewCellDelegate
|
||||
return cell
|
||||
case .bottomLoader(let attribute):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) as! TimelineBottomLoaderTableViewCell
|
||||
if attribute.isNoResult {
|
||||
cell.stopAnimating()
|
||||
cell.loadMoreLabel.text = L10n.Scene.Search.Searching.EmptyState.noResults
|
||||
cell.loadMoreLabel.textColor = Asset.Colors.Label.secondary.color
|
||||
cell.loadMoreLabel.isHidden = false
|
||||
} else {
|
||||
cell.startAnimating()
|
||||
cell.loadMoreLabel.isHidden = true
|
||||
}
|
||||
return cell
|
||||
default:
|
||||
fatalError()
|
||||
} // end switch
|
||||
} // end UITableViewDiffableDataSource
|
||||
} // end func
|
||||
}
|
|
@ -1,53 +0,0 @@
|
|||
//
|
||||
// SearchResultSection.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/4/6.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MastodonSDK
|
||||
import UIKit
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
|
||||
enum SearchResultSection: Equatable, Hashable {
|
||||
case account
|
||||
case hashtag
|
||||
case mixed
|
||||
case bottomLoader
|
||||
}
|
||||
|
||||
extension SearchResultSection {
|
||||
static func tableViewDiffableDataSource(
|
||||
for tableView: UITableView,
|
||||
dependency: NeedsDependency
|
||||
) -> UITableViewDiffableDataSource<SearchResultSection, SearchResultItem> {
|
||||
UITableViewDiffableDataSource(tableView: tableView) { (tableView, indexPath, result) -> UITableViewCell? in
|
||||
switch result {
|
||||
case .account(let account):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell
|
||||
cell.config(with: account)
|
||||
return cell
|
||||
case .hashtag(let tag):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell
|
||||
cell.config(with: tag)
|
||||
return cell
|
||||
case .hashtagObjectID(let hashtagObjectID):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell
|
||||
let tag = dependency.context.managedObjectContext.object(with: hashtagObjectID) as! Tag
|
||||
cell.config(with: tag)
|
||||
return cell
|
||||
case .accountObjectID(let accountObjectID):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell
|
||||
let user = dependency.context.managedObjectContext.object(with: accountObjectID) as! MastodonUser
|
||||
cell.config(with: user)
|
||||
return cell
|
||||
case .bottomLoader:
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) as! TimelineBottomLoaderTableViewCell
|
||||
cell.startAnimating()
|
||||
return cell
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -9,7 +9,6 @@ import Foundation
|
|||
|
||||
enum SettingsSection: Hashable {
|
||||
case appearance
|
||||
case appearanceSettings
|
||||
case notifications
|
||||
case preference
|
||||
case boringZone
|
||||
|
@ -18,11 +17,10 @@ enum SettingsSection: Hashable {
|
|||
var title: String {
|
||||
switch self {
|
||||
case .appearance: return L10n.Scene.Settings.Section.Appearance.title
|
||||
case .appearanceSettings: return ""
|
||||
case .notifications: return L10n.Scene.Settings.Section.Notifications.title
|
||||
case .preference: return L10n.Scene.Settings.Section.Preference.title
|
||||
case .boringZone: return L10n.Scene.Settings.Section.Boringzone.title
|
||||
case .spicyZone: return L10n.Scene.Settings.Section.Spicyzone.title
|
||||
case .boringZone: return L10n.Scene.Settings.Section.BoringZone.title
|
||||
case .spicyZone: return L10n.Scene.Settings.Section.SpicyZone.title
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -60,6 +60,8 @@ extension StatusSection {
|
|||
}
|
||||
#endif
|
||||
|
||||
static let logger = Logger(subsystem: "StatusSection", category: "logic")
|
||||
|
||||
static func tableViewDiffableDataSource(
|
||||
for tableView: UITableView,
|
||||
timelineContext: TimelineContext,
|
||||
|
@ -205,6 +207,12 @@ extension StatusSection {
|
|||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell
|
||||
cell.startAnimating()
|
||||
return cell
|
||||
case .emptyBottomLoader:
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell
|
||||
cell.stopAnimating()
|
||||
cell.loadMoreLabel.text = " "
|
||||
cell.loadMoreLabel.isHidden = false
|
||||
return cell
|
||||
case .emptyStateHeader(let attribute):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineHeaderTableViewCell.self), for: indexPath) as! TimelineHeaderTableViewCell
|
||||
StatusSection.configureEmptyStateHeader(cell: cell, attribute: attribute)
|
||||
|
@ -228,6 +236,7 @@ extension StatusSection {
|
|||
case favorite
|
||||
case hashtag
|
||||
case report
|
||||
case search
|
||||
|
||||
var filterContext: Mastodon.Entity.Filter.Context? {
|
||||
switch self {
|
||||
|
@ -247,7 +256,8 @@ extension StatusSection {
|
|||
timelineContext: TimelineContext
|
||||
) -> AnyPublisher<Bool, Never> {
|
||||
guard let content = content,
|
||||
let currentFilterContext = timelineContext.filterContext else {
|
||||
let currentFilterContext = timelineContext.filterContext,
|
||||
!filters.isEmpty else {
|
||||
return Just(false).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
|
@ -351,18 +361,29 @@ extension StatusSection {
|
|||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
|
||||
let document = MastodonContent(
|
||||
content: (status.reblog ?? status).content,
|
||||
emojis: (status.reblog ?? status).emojiMeta
|
||||
)
|
||||
let content = try? MastodonMetaContent.convert(document: document)
|
||||
|
||||
let content: MastodonMetaContent? = {
|
||||
if let operation = dependency.context.statusPrefetchingService.statusContentOperations.removeValue(forKey: status.objectID),
|
||||
let result = operation.result {
|
||||
switch result {
|
||||
case .success(let content): return content
|
||||
case .failure: return nil
|
||||
}
|
||||
} else {
|
||||
let document = MastodonContent(
|
||||
content: (status.reblog ?? status).content,
|
||||
emojis: (status.reblog ?? status).emojiMeta
|
||||
)
|
||||
return try? MastodonMetaContent.convert(document: document)
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
if status.author.id == requestUserID || status.reblog?.author.id == requestUserID {
|
||||
// do not filter myself
|
||||
} else {
|
||||
let needsFilter = StatusSection.needsFilterStatus(
|
||||
content: content,
|
||||
filters: AppContext.shared.authenticationService.activeFilters.value,
|
||||
filters: AppContext.shared.statusFilterService.activeFilters.value,
|
||||
timelineContext: timelineContext
|
||||
)
|
||||
needsFilter
|
||||
|
@ -1129,3 +1150,44 @@ extension StatusSection {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
class StatusContentOperation: Operation {
|
||||
|
||||
let logger = Logger(subsystem: "StatusContentOperation", category: "logic")
|
||||
|
||||
// input
|
||||
let statusObjectID: NSManagedObjectID
|
||||
let mastodonContent: MastodonContent
|
||||
|
||||
// output
|
||||
var result: Result<MastodonMetaContent, Error>?
|
||||
|
||||
init(
|
||||
statusObjectID: NSManagedObjectID,
|
||||
mastodonContent: MastodonContent
|
||||
) {
|
||||
self.statusObjectID = statusObjectID
|
||||
self.mastodonContent = mastodonContent
|
||||
super.init()
|
||||
}
|
||||
|
||||
override func main() {
|
||||
guard !isCancelled else { return }
|
||||
// logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): prcoess \(self.statusObjectID)…")
|
||||
|
||||
do {
|
||||
let content = try MastodonMetaContent.convert(document: mastodonContent)
|
||||
result = .success(content)
|
||||
// logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): process success \(self.statusObjectID)")
|
||||
} catch {
|
||||
result = .failure(error)
|
||||
// logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): process fail \(self.statusObjectID)")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override func cancel() {
|
||||
// logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): cancel \(self.statusObjectID.debugDescription)")
|
||||
super.cancel()
|
||||
}
|
||||
}
|
|
@ -14,15 +14,15 @@ internal enum L10n {
|
|||
internal enum Common {
|
||||
internal enum Alerts {
|
||||
internal enum BlockDomain {
|
||||
/// Block entire domain
|
||||
/// Block Domain
|
||||
internal static let blockEntireDomain = L10n.tr("Localizable", "Common.Alerts.BlockDomain.BlockEntireDomain")
|
||||
/// Are you really, really sure you want to block the entire %@? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.
|
||||
/// Are you really, really sure you want to block the entire %@? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain and any of your followers from that domain will be removed.
|
||||
internal static func title(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "Common.Alerts.BlockDomain.Title", String(describing: p1))
|
||||
}
|
||||
}
|
||||
internal enum CleanCache {
|
||||
/// Successfully clean %@ cache.
|
||||
/// Successfully cleaned %@ cache.
|
||||
internal static func message(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "Common.Alerts.CleanCache.Message", String(describing: p1))
|
||||
}
|
||||
|
@ -42,9 +42,9 @@ internal enum L10n {
|
|||
internal static let title = L10n.tr("Localizable", "Common.Alerts.DeletePost.Title")
|
||||
}
|
||||
internal enum DiscardPostContent {
|
||||
/// Confirm discard composed post content.
|
||||
/// Confirm to discard composed post content.
|
||||
internal static let message = L10n.tr("Localizable", "Common.Alerts.DiscardPostContent.Message")
|
||||
/// Discard Publish
|
||||
/// Discard Draft
|
||||
internal static let title = L10n.tr("Localizable", "Common.Alerts.DiscardPostContent.Title")
|
||||
}
|
||||
internal enum EditProfileFailure {
|
||||
|
@ -61,12 +61,12 @@ internal enum L10n {
|
|||
internal enum AttchmentsMessage {
|
||||
/// Cannot attach more than one video.
|
||||
internal static let moreThanOneVideo = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.AttchmentsMessage.MoreThanOneVideo")
|
||||
/// Cannot attach a video to a status that already contains images.
|
||||
/// Cannot attach a video to a post that already contains images.
|
||||
internal static let videoAttachWithPhoto = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.AttchmentsMessage.VideoAttachWithPhoto")
|
||||
}
|
||||
}
|
||||
internal enum SavePhotoFailure {
|
||||
/// Please enable photo libaray access permission to save photo.
|
||||
/// Please enable the photo library access permission to save the photo.
|
||||
internal static let message = L10n.tr("Localizable", "Common.Alerts.SavePhotoFailure.Message")
|
||||
/// Save Photo Failure
|
||||
internal static let title = L10n.tr("Localizable", "Common.Alerts.SavePhotoFailure.Title")
|
||||
|
@ -80,7 +80,7 @@ internal enum L10n {
|
|||
internal static let confirm = L10n.tr("Localizable", "Common.Alerts.SignOut.Confirm")
|
||||
/// Are you sure you want to sign out?
|
||||
internal static let message = L10n.tr("Localizable", "Common.Alerts.SignOut.Message")
|
||||
/// Sign out
|
||||
/// Sign Out
|
||||
internal static let title = L10n.tr("Localizable", "Common.Alerts.SignOut.Title")
|
||||
}
|
||||
internal enum SignUpFailure {
|
||||
|
@ -239,25 +239,25 @@ internal enum L10n {
|
|||
internal static let previousSection = L10n.tr("Localizable", "Common.Controls.Keyboard.SegmentedControl.PreviousSection")
|
||||
}
|
||||
internal enum Timeline {
|
||||
/// Next Status
|
||||
/// Next Post
|
||||
internal static let nextStatus = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.NextStatus")
|
||||
/// Open Author Profile
|
||||
/// Open Author's Profile
|
||||
internal static let openAuthorProfile = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.OpenAuthorProfile")
|
||||
/// Open Reblogger Profile
|
||||
/// Open Reblogger's Profile
|
||||
internal static let openRebloggerProfile = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.OpenRebloggerProfile")
|
||||
/// Open Status
|
||||
/// Open Post
|
||||
internal static let openStatus = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.OpenStatus")
|
||||
/// Preview Image
|
||||
internal static let previewImage = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.PreviewImage")
|
||||
/// Previous Status
|
||||
/// Previous Post
|
||||
internal static let previousStatus = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.PreviousStatus")
|
||||
/// Reply Status
|
||||
/// Reply to Post
|
||||
internal static let replyStatus = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.ReplyStatus")
|
||||
/// Toggle Content Warning
|
||||
internal static let toggleContentWarning = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.ToggleContentWarning")
|
||||
/// Toggle Status Favorite
|
||||
/// Toggle Favorite on Post
|
||||
internal static let toggleFavorite = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.ToggleFavorite")
|
||||
/// Toggle Status Reblog
|
||||
/// Toggle Reblog on Post
|
||||
internal static let toggleReblog = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.ToggleReblog")
|
||||
}
|
||||
}
|
||||
|
@ -289,7 +289,7 @@ internal enum L10n {
|
|||
internal static let reply = L10n.tr("Localizable", "Common.Controls.Status.Actions.Reply")
|
||||
/// Unfavorite
|
||||
internal static let unfavorite = L10n.tr("Localizable", "Common.Controls.Status.Actions.Unfavorite")
|
||||
/// Unreblog
|
||||
/// Undo reblog
|
||||
internal static let unreblog = L10n.tr("Localizable", "Common.Controls.Status.Actions.Unreblog")
|
||||
}
|
||||
internal enum Poll {
|
||||
|
@ -345,19 +345,19 @@ internal enum L10n {
|
|||
}
|
||||
}
|
||||
internal enum Header {
|
||||
/// You can’t view this’s profile\n until they unblock you.
|
||||
/// You can’t view this user’s profile\n until they unblock you.
|
||||
internal static let blockedWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.BlockedWarning")
|
||||
/// You can’t view this profile\n until you unblock them.\nYour account looks like this to them.
|
||||
/// You can’t view this user's profile\n until you unblock them.\nYour profile looks like this to them.
|
||||
internal static let blockingWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.BlockingWarning")
|
||||
/// No Status Found
|
||||
/// No Post Found
|
||||
internal static let noStatusFound = L10n.tr("Localizable", "Common.Controls.Timeline.Header.NoStatusFound")
|
||||
/// This account has been suspended.
|
||||
/// This user has been suspended.
|
||||
internal static let suspendedWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.SuspendedWarning")
|
||||
/// You can’t view %@’s profile\n until they unblock you.
|
||||
internal static func userBlockedWarning(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "Common.Controls.Timeline.Header.UserBlockedWarning", String(describing: p1))
|
||||
}
|
||||
/// You can’t view %@’s profile\n until you unblock them.\nYour account looks like this to them.
|
||||
/// You can’t view %@’s profile\n until you unblock them.\nYour profile looks like this to them.
|
||||
internal static func userBlockingWarning(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "Common.Controls.Timeline.Header.UserBlockingWarning", String(describing: p1))
|
||||
}
|
||||
|
@ -397,15 +397,15 @@ internal enum L10n {
|
|||
return L10n.tr("Localizable", "Scene.Compose.ReplyingToUser", String(describing: p1))
|
||||
}
|
||||
internal enum Accessibility {
|
||||
/// Append attachment
|
||||
/// Add Attachment
|
||||
internal static let appendAttachment = L10n.tr("Localizable", "Scene.Compose.Accessibility.AppendAttachment")
|
||||
/// Append poll
|
||||
/// Add Poll
|
||||
internal static let appendPoll = L10n.tr("Localizable", "Scene.Compose.Accessibility.AppendPoll")
|
||||
/// Custom emoji picker
|
||||
/// Custom Emoji Picker
|
||||
internal static let customEmojiPicker = L10n.tr("Localizable", "Scene.Compose.Accessibility.CustomEmojiPicker")
|
||||
/// Disable content warning
|
||||
/// Disable Content Warning
|
||||
internal static let disableContentWarning = L10n.tr("Localizable", "Scene.Compose.Accessibility.DisableContentWarning")
|
||||
/// Enable content warning
|
||||
/// Enable Content Warning
|
||||
internal static let enableContentWarning = L10n.tr("Localizable", "Scene.Compose.Accessibility.EnableContentWarning")
|
||||
/// Input limit exceeds %ld
|
||||
internal static func inputLimitExceedsCount(_ p1: Int) -> String {
|
||||
|
@ -415,9 +415,9 @@ internal enum L10n {
|
|||
internal static func inputLimitRemainsCount(_ p1: Int) -> String {
|
||||
return L10n.tr("Localizable", "Scene.Compose.Accessibility.InputLimitRemainsCount", p1)
|
||||
}
|
||||
/// Post visibility menu
|
||||
/// Post Visibility Menu
|
||||
internal static let postVisibilityMenu = L10n.tr("Localizable", "Scene.Compose.Accessibility.PostVisibilityMenu")
|
||||
/// Remove poll
|
||||
/// Remove Poll
|
||||
internal static let removePoll = L10n.tr("Localizable", "Scene.Compose.Accessibility.RemovePoll")
|
||||
}
|
||||
internal enum Attachment {
|
||||
|
@ -425,9 +425,9 @@ internal enum L10n {
|
|||
internal static func attachmentBroken(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "Scene.Compose.Attachment.AttachmentBroken", String(describing: p1))
|
||||
}
|
||||
/// Describe photo for low vision people...
|
||||
/// Describe the photo for the visually-impaired...
|
||||
internal static let descriptionPhoto = L10n.tr("Localizable", "Scene.Compose.Attachment.DescriptionPhoto")
|
||||
/// Describe what’s happening for low vision people...
|
||||
/// Describe the video for the visually-impaired...
|
||||
internal static let descriptionVideo = L10n.tr("Localizable", "Scene.Compose.Attachment.DescriptionVideo")
|
||||
/// photo
|
||||
internal static let photo = L10n.tr("Localizable", "Scene.Compose.Attachment.Photo")
|
||||
|
@ -443,7 +443,7 @@ internal enum L10n {
|
|||
internal static let placeholder = L10n.tr("Localizable", "Scene.Compose.ContentWarning.Placeholder")
|
||||
}
|
||||
internal enum Keyboard {
|
||||
/// Append Attachment - %@
|
||||
/// Add Attachment - %@
|
||||
internal static func appendAttachmentEntry(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "Scene.Compose.Keyboard.AppendAttachmentEntry", String(describing: p1))
|
||||
}
|
||||
|
@ -569,13 +569,13 @@ internal enum L10n {
|
|||
internal static let favourite = L10n.tr("Localizable", "Scene.Notification.Action.Favourite")
|
||||
/// followed you
|
||||
internal static let follow = L10n.tr("Localizable", "Scene.Notification.Action.Follow")
|
||||
/// request to follow you
|
||||
/// requested to follow you
|
||||
internal static let followRequest = L10n.tr("Localizable", "Scene.Notification.Action.FollowRequest")
|
||||
/// mentioned you
|
||||
internal static let mention = L10n.tr("Localizable", "Scene.Notification.Action.Mention")
|
||||
/// Your poll has ended
|
||||
internal static let poll = L10n.tr("Localizable", "Scene.Notification.Action.Poll")
|
||||
/// rebloged your post
|
||||
/// reblogged your post
|
||||
internal static let reblog = L10n.tr("Localizable", "Scene.Notification.Action.Reblog")
|
||||
}
|
||||
internal enum Keyobard {
|
||||
|
@ -636,7 +636,7 @@ internal enum L10n {
|
|||
}
|
||||
internal enum RelationshipActionAlert {
|
||||
internal enum ConfirmUnblockUsre {
|
||||
/// Confirm unblock %@
|
||||
/// Confirm to unblock %@
|
||||
internal static func message(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message", String(describing: p1))
|
||||
}
|
||||
|
@ -644,7 +644,7 @@ internal enum L10n {
|
|||
internal static let title = L10n.tr("Localizable", "Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title")
|
||||
}
|
||||
internal enum ConfirmUnmuteUser {
|
||||
/// Confirm unmute %@
|
||||
/// Confirm to unmute %@
|
||||
internal static func message(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message", String(describing: p1))
|
||||
}
|
||||
|
@ -692,7 +692,7 @@ internal enum L10n {
|
|||
internal static func blank(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "Scene.Register.Error.Reason.Blank", String(describing: p1))
|
||||
}
|
||||
/// %@ contains a disallowed e-mail provider
|
||||
/// %@ contains a disallowed email provider
|
||||
internal static func blocked(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "Scene.Register.Error.Reason.Blocked", String(describing: p1))
|
||||
}
|
||||
|
@ -726,7 +726,7 @@ internal enum L10n {
|
|||
}
|
||||
}
|
||||
internal enum Special {
|
||||
/// This is not a valid e-mail address
|
||||
/// This is not a valid email address
|
||||
internal static let emailInvalid = L10n.tr("Localizable", "Scene.Register.Error.Special.EmailInvalid")
|
||||
/// Password is too short (must be at least 8 characters)
|
||||
internal static let passwordTooShort = L10n.tr("Localizable", "Scene.Register.Error.Special.PasswordTooShort")
|
||||
|
@ -788,6 +788,8 @@ internal enum L10n {
|
|||
}
|
||||
}
|
||||
internal enum Search {
|
||||
/// Search
|
||||
internal static let title = L10n.tr("Localizable", "Scene.Search.Title")
|
||||
internal enum Recommend {
|
||||
/// See All
|
||||
internal static let buttonText = L10n.tr("Localizable", "Scene.Search.Recommend.ButtonText")
|
||||
|
@ -800,27 +802,31 @@ internal enum L10n {
|
|||
internal static let title = L10n.tr("Localizable", "Scene.Search.Recommend.Accounts.Title")
|
||||
}
|
||||
internal enum HashTag {
|
||||
/// Hashtags that are getting quite a bit of attention among people you follow
|
||||
/// Hashtags that are getting quite a bit of attention
|
||||
internal static let description = L10n.tr("Localizable", "Scene.Search.Recommend.HashTag.Description")
|
||||
/// %@ people are talking
|
||||
internal static func peopleTalking(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "Scene.Search.Recommend.HashTag.PeopleTalking", String(describing: p1))
|
||||
}
|
||||
/// Trending in your timeline
|
||||
/// Trending on Mastodon
|
||||
internal static let title = L10n.tr("Localizable", "Scene.Search.Recommend.HashTag.Title")
|
||||
}
|
||||
}
|
||||
internal enum Searchbar {
|
||||
internal enum SearchBar {
|
||||
/// Cancel
|
||||
internal static let cancel = L10n.tr("Localizable", "Scene.Search.Searchbar.Cancel")
|
||||
internal static let cancel = L10n.tr("Localizable", "Scene.Search.SearchBar.Cancel")
|
||||
/// Search hashtags and users
|
||||
internal static let placeholder = L10n.tr("Localizable", "Scene.Search.Searchbar.Placeholder")
|
||||
internal static let placeholder = L10n.tr("Localizable", "Scene.Search.SearchBar.Placeholder")
|
||||
}
|
||||
internal enum Searching {
|
||||
/// Clear
|
||||
internal static let clear = L10n.tr("Localizable", "Scene.Search.Searching.Clear")
|
||||
/// Recent searches
|
||||
internal static let recentSearch = L10n.tr("Localizable", "Scene.Search.Searching.RecentSearch")
|
||||
internal enum EmptyState {
|
||||
/// No results
|
||||
internal static let noResults = L10n.tr("Localizable", "Scene.Search.Searching.EmptyState.NoResults")
|
||||
}
|
||||
internal enum Segment {
|
||||
/// All
|
||||
internal static let all = L10n.tr("Localizable", "Scene.Search.Searching.Segment.All")
|
||||
|
@ -828,6 +834,8 @@ internal enum L10n {
|
|||
internal static let hashtags = L10n.tr("Localizable", "Scene.Search.Searching.Segment.Hashtags")
|
||||
/// People
|
||||
internal static let people = L10n.tr("Localizable", "Scene.Search.Searching.Segment.People")
|
||||
/// Posts
|
||||
internal static let posts = L10n.tr("Localizable", "Scene.Search.Searching.Segment.Posts")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -871,7 +879,7 @@ internal enum L10n {
|
|||
}
|
||||
}
|
||||
internal enum EmptyState {
|
||||
/// Something went wrong while loading data. Check your internet connection.
|
||||
/// Something went wrong while loading the data. Check your internet connection.
|
||||
internal static let badNetwork = L10n.tr("Localizable", "Scene.ServerPicker.EmptyState.BadNetwork")
|
||||
/// Finding available servers...
|
||||
internal static let findingServers = L10n.tr("Localizable", "Scene.ServerPicker.EmptyState.FindingServers")
|
||||
|
@ -936,18 +944,20 @@ internal enum L10n {
|
|||
internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Appearance.Title")
|
||||
}
|
||||
internal enum AppearanceSettings {
|
||||
/// Disable avatar animation
|
||||
/// Disable animated avatars
|
||||
internal static let disableAvatarAnimation = L10n.tr("Localizable", "Scene.Settings.Section.AppearanceSettings.DisableAvatarAnimation")
|
||||
/// True black Dark Mode
|
||||
/// True black dark mode
|
||||
internal static let trueBlackDarkMode = L10n.tr("Localizable", "Scene.Settings.Section.AppearanceSettings.TrueBlackDarkMode")
|
||||
}
|
||||
internal enum Boringzone {
|
||||
internal enum BoringZone {
|
||||
/// Account settings
|
||||
internal static let accountSettings = L10n.tr("Localizable", "Scene.Settings.Section.BoringZone.AccountSettings")
|
||||
/// Privacy Policy
|
||||
internal static let privacy = L10n.tr("Localizable", "Scene.Settings.Section.Boringzone.Privacy")
|
||||
internal static let privacy = L10n.tr("Localizable", "Scene.Settings.Section.BoringZone.Privacy")
|
||||
/// Terms of Service
|
||||
internal static let terms = L10n.tr("Localizable", "Scene.Settings.Section.Boringzone.Terms")
|
||||
/// The Boring zone
|
||||
internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Boringzone.Title")
|
||||
internal static let terms = L10n.tr("Localizable", "Scene.Settings.Section.BoringZone.Terms")
|
||||
/// The Boring Zone
|
||||
internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.BoringZone.Title")
|
||||
}
|
||||
internal enum Notifications {
|
||||
/// Reblogs my post
|
||||
|
@ -974,18 +984,18 @@ internal enum L10n {
|
|||
}
|
||||
}
|
||||
internal enum Preference {
|
||||
/// Preference
|
||||
/// Preferences
|
||||
internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Preference.Title")
|
||||
/// Using default browser open link
|
||||
/// Use default browser to open links
|
||||
internal static let usingDefaultBrowser = L10n.tr("Localizable", "Scene.Settings.Section.Preference.UsingDefaultBrowser")
|
||||
}
|
||||
internal enum Spicyzone {
|
||||
internal enum SpicyZone {
|
||||
/// Clear Media Cache
|
||||
internal static let clear = L10n.tr("Localizable", "Scene.Settings.Section.Spicyzone.Clear")
|
||||
internal static let clear = L10n.tr("Localizable", "Scene.Settings.Section.SpicyZone.Clear")
|
||||
/// Sign Out
|
||||
internal static let signout = L10n.tr("Localizable", "Scene.Settings.Section.Spicyzone.Signout")
|
||||
/// The spicy zone
|
||||
internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Spicyzone.Title")
|
||||
internal static let signout = L10n.tr("Localizable", "Scene.Settings.Section.SpicyZone.Signout")
|
||||
/// The Spicy Zone
|
||||
internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.SpicyZone.Title")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,9 @@ import CoreDataStack
|
|||
|
||||
extension StatusTableViewCellDelegate where Self: StatusProvider {
|
||||
func handleTableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
|
||||
let statusObjectItems = self.statusObjectItems(indexPaths: indexPaths)
|
||||
self.context.statusPrefetchingService.prefetch(statusObjectItems: statusObjectItems)
|
||||
|
||||
// prefetch reply status
|
||||
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return }
|
||||
let domain = activeMastodonAuthenticationBox.domain
|
||||
|
@ -47,4 +50,9 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
|
|||
} // end for in
|
||||
} // end context.perform
|
||||
} // end func
|
||||
|
||||
func handleTableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
|
||||
let statusObjectItems = self.statusObjectItems(indexPaths: indexPaths)
|
||||
self.context.statusPrefetchingService.cancelPrefetch(statusObjectItems: statusObjectItems)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,10 +22,16 @@ protocol StatusProvider: NeedsDependency & DisposeBagCollectable & UIViewControl
|
|||
|
||||
// sync
|
||||
var managedObjectContext: NSManagedObjectContext { get }
|
||||
|
||||
@available(*, deprecated)
|
||||
var tableViewDiffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? { get }
|
||||
@available(*, deprecated)
|
||||
func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item?
|
||||
@available(*, deprecated)
|
||||
func items(indexPaths: [IndexPath]) -> [Item]
|
||||
|
||||
func statusObjectItems(indexPaths: [IndexPath]) -> [StatusObjectItem]
|
||||
|
||||
#if ASDK
|
||||
func status(node: ASCellNode?, indexPath: IndexPath?) -> Status?
|
||||
#endif
|
||||
|
@ -38,3 +44,9 @@ extension StatusProvider {
|
|||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
enum StatusObjectItem {
|
||||
case status(objectID: NSManagedObjectID)
|
||||
case homeTimelineIndex(objectID: NSManagedObjectID)
|
||||
case mastodonNotification(objectID: NSManagedObjectID) // may not contains status
|
||||
}
|
||||
|
|
|
@ -145,8 +145,8 @@ extension StatusProviderFacade {
|
|||
provider.coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil))
|
||||
}
|
||||
case .hashtag(let text, _):
|
||||
let hashtagTimelienViewModel = HashtagTimelineViewModel(context: provider.context, hashtag: text)
|
||||
provider.coordinator.present(scene: .hashtagTimeline(viewModel: hashtagTimelienViewModel), from: provider, transition: .show)
|
||||
let hashtagTimelineViewModel = HashtagTimelineViewModel(context: provider.context, hashtag: text)
|
||||
provider.coordinator.present(scene: .hashtagTimeline(viewModel: hashtagTimelineViewModel), from: provider, transition: .show)
|
||||
case .mention(let text, let userInfo):
|
||||
let href = userInfo?["href"] as? String
|
||||
coordinateToStatusMentionProfileScene(for: .primary, provider: provider, cell: cell, mention: text, href: href)
|
||||
|
|
|
@ -10,12 +10,13 @@ import AVKit
|
|||
import GameController
|
||||
|
||||
// Check List Last Updated
|
||||
// - HomeViewController: 2021/4/30
|
||||
// - HomeViewController: 2021/7/15
|
||||
// - FavoriteViewController: 2021/4/30
|
||||
// - HashtagTimelineViewController: 2021/4/30
|
||||
// - UserTimelineViewController: 2021/4/30
|
||||
// - ThreadViewController: 2021/4/30
|
||||
// * StatusTableViewControllerAspect: 2021/4/30
|
||||
// - SearchResultViewController: 2021/7/15
|
||||
// * StatusTableViewControllerAspect: 2021/7/15
|
||||
|
||||
// (Fake) Aspect protocol to group common protocol extension implementations
|
||||
// Needs update related view controller when aspect interface changes
|
||||
|
@ -45,6 +46,7 @@ extension StatusTableViewControllerAspect {
|
|||
}
|
||||
}
|
||||
|
||||
// [A2] aspectViewDidDisappear(_:)
|
||||
extension StatusTableViewControllerAspect where Self: NeedsDependency {
|
||||
/// [Media] hook to notify video service
|
||||
func aspectViewDidDisappear(_ animated: Bool) {
|
||||
|
@ -146,12 +148,20 @@ extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegat
|
|||
|
||||
// [C1] aspectTableView(:prefetchRowsAt)
|
||||
extension StatusTableViewControllerAspect where Self: UITableViewDataSourcePrefetching & StatusTableViewCellDelegate & StatusProvider {
|
||||
/// [Data Source] hook to prefetch reply to info for status
|
||||
/// [Data Source] hook to prefetch status
|
||||
func aspectTableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
|
||||
handleTableView(tableView, prefetchRowsAt: indexPaths)
|
||||
}
|
||||
}
|
||||
|
||||
// [C2] aspectTableView(:prefetchRowsAt)
|
||||
extension StatusTableViewControllerAspect where Self: UITableViewDataSourcePrefetching & StatusTableViewCellDelegate & StatusProvider {
|
||||
/// [Data Source] hook to cancel prefetch status
|
||||
func aspectTableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
|
||||
handleTableView(tableView, cancelPrefetchingForRowsAt: indexPaths)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AVPlayerViewControllerDelegate & NeedsDependency [D]
|
||||
|
||||
// [D1] aspectPlayerViewController(_:willBeginFullScreenPresentationWithAnimationCoordinator:)
|
||||
|
|
|
@ -1,26 +1,26 @@
|
|||
"Common.Alerts.BlockDomain.BlockEntireDomain" = "Block entire domain";
|
||||
"Common.Alerts.BlockDomain.Title" = "Are you really, really sure you want to block the entire %@? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.";
|
||||
"Common.Alerts.CleanCache.Message" = "Successfully clean %@ cache.";
|
||||
"Common.Alerts.BlockDomain.BlockEntireDomain" = "Block Domain";
|
||||
"Common.Alerts.BlockDomain.Title" = "Are you really, really sure you want to block the entire %@? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain and any of your followers from that domain will be removed.";
|
||||
"Common.Alerts.CleanCache.Message" = "Successfully cleaned %@ cache.";
|
||||
"Common.Alerts.CleanCache.Title" = "Clean Cache";
|
||||
"Common.Alerts.Common.PleaseTryAgain" = "Please try again.";
|
||||
"Common.Alerts.Common.PleaseTryAgainLater" = "Please try again later.";
|
||||
"Common.Alerts.DeletePost.Delete" = "Delete";
|
||||
"Common.Alerts.DeletePost.Title" = "Are you sure you want to delete this post?";
|
||||
"Common.Alerts.DiscardPostContent.Message" = "Confirm discard composed post content.";
|
||||
"Common.Alerts.DiscardPostContent.Title" = "Discard Publish";
|
||||
"Common.Alerts.DiscardPostContent.Message" = "Confirm to discard composed post content.";
|
||||
"Common.Alerts.DiscardPostContent.Title" = "Discard Draft";
|
||||
"Common.Alerts.EditProfileFailure.Message" = "Cannot edit profile. Please try again.";
|
||||
"Common.Alerts.EditProfileFailure.Title" = "Edit Profile Error";
|
||||
"Common.Alerts.PublishPostFailure.AttchmentsMessage.MoreThanOneVideo" = "Cannot attach more than one video.";
|
||||
"Common.Alerts.PublishPostFailure.AttchmentsMessage.VideoAttachWithPhoto" = "Cannot attach a video to a status that already contains images.";
|
||||
"Common.Alerts.PublishPostFailure.AttchmentsMessage.VideoAttachWithPhoto" = "Cannot attach a video to a post that already contains images.";
|
||||
"Common.Alerts.PublishPostFailure.Message" = "Failed to publish the post.
|
||||
Please check your internet connection.";
|
||||
"Common.Alerts.PublishPostFailure.Title" = "Publish Failure";
|
||||
"Common.Alerts.SavePhotoFailure.Message" = "Please enable photo libaray access permission to save photo.";
|
||||
"Common.Alerts.SavePhotoFailure.Message" = "Please enable the photo library access permission to save the photo.";
|
||||
"Common.Alerts.SavePhotoFailure.Title" = "Save Photo Failure";
|
||||
"Common.Alerts.ServerError.Title" = "Server Error";
|
||||
"Common.Alerts.SignOut.Confirm" = "Sign Out";
|
||||
"Common.Alerts.SignOut.Message" = "Are you sure you want to sign out?";
|
||||
"Common.Alerts.SignOut.Title" = "Sign out";
|
||||
"Common.Alerts.SignOut.Title" = "Sign Out";
|
||||
"Common.Alerts.SignUpFailure.Title" = "Sign Up Failure";
|
||||
"Common.Alerts.VoteFailure.PollExpired" = "The poll has expired";
|
||||
"Common.Alerts.VoteFailure.Title" = "Vote Failure";
|
||||
|
@ -81,22 +81,22 @@ Please check your internet connection.";
|
|||
"Common.Controls.Keyboard.Common.SwitchToTab" = "Switch to %@";
|
||||
"Common.Controls.Keyboard.SegmentedControl.NextSection" = "Next Section";
|
||||
"Common.Controls.Keyboard.SegmentedControl.PreviousSection" = "Previous Section";
|
||||
"Common.Controls.Keyboard.Timeline.NextStatus" = "Next Status";
|
||||
"Common.Controls.Keyboard.Timeline.OpenAuthorProfile" = "Open Author Profile";
|
||||
"Common.Controls.Keyboard.Timeline.OpenRebloggerProfile" = "Open Reblogger Profile";
|
||||
"Common.Controls.Keyboard.Timeline.OpenStatus" = "Open Status";
|
||||
"Common.Controls.Keyboard.Timeline.NextStatus" = "Next Post";
|
||||
"Common.Controls.Keyboard.Timeline.OpenAuthorProfile" = "Open Author's Profile";
|
||||
"Common.Controls.Keyboard.Timeline.OpenRebloggerProfile" = "Open Reblogger's Profile";
|
||||
"Common.Controls.Keyboard.Timeline.OpenStatus" = "Open Post";
|
||||
"Common.Controls.Keyboard.Timeline.PreviewImage" = "Preview Image";
|
||||
"Common.Controls.Keyboard.Timeline.PreviousStatus" = "Previous Status";
|
||||
"Common.Controls.Keyboard.Timeline.ReplyStatus" = "Reply Status";
|
||||
"Common.Controls.Keyboard.Timeline.PreviousStatus" = "Previous Post";
|
||||
"Common.Controls.Keyboard.Timeline.ReplyStatus" = "Reply to Post";
|
||||
"Common.Controls.Keyboard.Timeline.ToggleContentWarning" = "Toggle Content Warning";
|
||||
"Common.Controls.Keyboard.Timeline.ToggleFavorite" = "Toggle Status Favorite";
|
||||
"Common.Controls.Keyboard.Timeline.ToggleReblog" = "Toggle Status Reblog";
|
||||
"Common.Controls.Keyboard.Timeline.ToggleFavorite" = "Toggle Favorite on Post";
|
||||
"Common.Controls.Keyboard.Timeline.ToggleReblog" = "Toggle Reblog on Post";
|
||||
"Common.Controls.Status.Actions.Favorite" = "Favorite";
|
||||
"Common.Controls.Status.Actions.Menu" = "Menu";
|
||||
"Common.Controls.Status.Actions.Reblog" = "Reblog";
|
||||
"Common.Controls.Status.Actions.Reply" = "Reply";
|
||||
"Common.Controls.Status.Actions.Unfavorite" = "Unfavorite";
|
||||
"Common.Controls.Status.Actions.Unreblog" = "Unreblog";
|
||||
"Common.Controls.Status.Actions.Unreblog" = "Undo reblog";
|
||||
"Common.Controls.Status.ContentWarning" = "Content Warning";
|
||||
"Common.Controls.Status.MediaContentWarning" = "Tap anywhere to reveal";
|
||||
"Common.Controls.Status.Poll.Closed" = "Closed";
|
||||
|
@ -120,44 +120,44 @@ Please check your internet connection.";
|
|||
"Common.Controls.Timeline.Accessibility.CountReblogs" = "%@ reblogs";
|
||||
"Common.Controls.Timeline.Accessibility.CountReplies" = "%@ replies";
|
||||
"Common.Controls.Timeline.Filtered" = "Filtered";
|
||||
"Common.Controls.Timeline.Header.BlockedWarning" = "You can’t view this’s profile
|
||||
"Common.Controls.Timeline.Header.BlockedWarning" = "You can’t view this user’s profile
|
||||
until they unblock you.";
|
||||
"Common.Controls.Timeline.Header.BlockingWarning" = "You can’t view this profile
|
||||
"Common.Controls.Timeline.Header.BlockingWarning" = "You can’t view this user's profile
|
||||
until you unblock them.
|
||||
Your account looks like this to them.";
|
||||
"Common.Controls.Timeline.Header.NoStatusFound" = "No Status Found";
|
||||
"Common.Controls.Timeline.Header.SuspendedWarning" = "This account has been suspended.";
|
||||
Your profile looks like this to them.";
|
||||
"Common.Controls.Timeline.Header.NoStatusFound" = "No Post Found";
|
||||
"Common.Controls.Timeline.Header.SuspendedWarning" = "This user has been suspended.";
|
||||
"Common.Controls.Timeline.Header.UserBlockedWarning" = "You can’t view %@’s profile
|
||||
until they unblock you.";
|
||||
"Common.Controls.Timeline.Header.UserBlockingWarning" = "You can’t view %@’s profile
|
||||
until you unblock them.
|
||||
Your account looks like this to them.";
|
||||
Your profile looks like this to them.";
|
||||
"Common.Controls.Timeline.Header.UserSuspendedWarning" = "%@’s account has been suspended.";
|
||||
"Common.Controls.Timeline.Loader.LoadMissingPosts" = "Load missing posts";
|
||||
"Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts...";
|
||||
"Common.Controls.Timeline.Loader.ShowMoreReplies" = "Show more replies";
|
||||
"Common.Controls.Timeline.Timestamp.Now" = "Now";
|
||||
"Common.Controls.Timeline.Timestamp.TimeAgo" = "%@ ago";
|
||||
"Scene.Compose.Accessibility.AppendAttachment" = "Append attachment";
|
||||
"Scene.Compose.Accessibility.AppendPoll" = "Append poll";
|
||||
"Scene.Compose.Accessibility.CustomEmojiPicker" = "Custom emoji picker";
|
||||
"Scene.Compose.Accessibility.DisableContentWarning" = "Disable content warning";
|
||||
"Scene.Compose.Accessibility.EnableContentWarning" = "Enable content warning";
|
||||
"Scene.Compose.Accessibility.AppendAttachment" = "Add Attachment";
|
||||
"Scene.Compose.Accessibility.AppendPoll" = "Add Poll";
|
||||
"Scene.Compose.Accessibility.CustomEmojiPicker" = "Custom Emoji Picker";
|
||||
"Scene.Compose.Accessibility.DisableContentWarning" = "Disable Content Warning";
|
||||
"Scene.Compose.Accessibility.EnableContentWarning" = "Enable Content Warning";
|
||||
"Scene.Compose.Accessibility.InputLimitExceedsCount" = "Input limit exceeds %ld";
|
||||
"Scene.Compose.Accessibility.InputLimitRemainsCount" = "Input limit remains %ld";
|
||||
"Scene.Compose.Accessibility.PostVisibilityMenu" = "Post visibility menu";
|
||||
"Scene.Compose.Accessibility.RemovePoll" = "Remove poll";
|
||||
"Scene.Compose.Accessibility.PostVisibilityMenu" = "Post Visibility Menu";
|
||||
"Scene.Compose.Accessibility.RemovePoll" = "Remove Poll";
|
||||
"Scene.Compose.Attachment.AttachmentBroken" = "This %@ is broken and can’t be
|
||||
uploaded to Mastodon.";
|
||||
"Scene.Compose.Attachment.DescriptionPhoto" = "Describe photo for low vision people...";
|
||||
"Scene.Compose.Attachment.DescriptionVideo" = "Describe what’s happening for low vision people...";
|
||||
"Scene.Compose.Attachment.DescriptionPhoto" = "Describe the photo for the visually-impaired...";
|
||||
"Scene.Compose.Attachment.DescriptionVideo" = "Describe the video for the visually-impaired...";
|
||||
"Scene.Compose.Attachment.Photo" = "photo";
|
||||
"Scene.Compose.Attachment.Video" = "video";
|
||||
"Scene.Compose.AutoComplete.SpaceToAdd" = "Space to add";
|
||||
"Scene.Compose.ComposeAction" = "Publish";
|
||||
"Scene.Compose.ContentInputPlaceholder" = "Type or paste what’s on your mind";
|
||||
"Scene.Compose.ContentWarning.Placeholder" = "Write an accurate warning here...";
|
||||
"Scene.Compose.Keyboard.AppendAttachmentEntry" = "Append Attachment - %@";
|
||||
"Scene.Compose.Keyboard.AppendAttachmentEntry" = "Add Attachment - %@";
|
||||
"Scene.Compose.Keyboard.DiscardPost" = "Discard Post";
|
||||
"Scene.Compose.Keyboard.PublishPost" = "Publish Post";
|
||||
"Scene.Compose.Keyboard.SelectVisibilityEntry" = "Select Visibility - %@";
|
||||
|
@ -202,10 +202,10 @@ tap the link to confirm your account.";
|
|||
"Scene.HomeTimeline.Title" = "Home";
|
||||
"Scene.Notification.Action.Favourite" = "favorited your post";
|
||||
"Scene.Notification.Action.Follow" = "followed you";
|
||||
"Scene.Notification.Action.FollowRequest" = "request to follow you";
|
||||
"Scene.Notification.Action.FollowRequest" = "requested to follow you";
|
||||
"Scene.Notification.Action.Mention" = "mentioned you";
|
||||
"Scene.Notification.Action.Poll" = "Your poll has ended";
|
||||
"Scene.Notification.Action.Reblog" = "rebloged your post";
|
||||
"Scene.Notification.Action.Reblog" = "reblogged your post";
|
||||
"Scene.Notification.Keyobard.ShowEverything" = "Show Everything";
|
||||
"Scene.Notification.Keyobard.ShowMentions" = "Show Mentions";
|
||||
"Scene.Notification.Title.Everything" = "Everything";
|
||||
|
@ -222,9 +222,9 @@ tap the link to confirm your account.";
|
|||
"Scene.Profile.Fields.AddRow" = "Add Row";
|
||||
"Scene.Profile.Fields.Placeholder.Content" = "Content";
|
||||
"Scene.Profile.Fields.Placeholder.Label" = "Label";
|
||||
"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message" = "Confirm unblock %@";
|
||||
"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message" = "Confirm to unblock %@";
|
||||
"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title" = "Unblock Account";
|
||||
"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Confirm unmute %@";
|
||||
"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Confirm to unmute %@";
|
||||
"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Title" = "Unmute Account";
|
||||
"Scene.Profile.SegmentedControl.Media" = "Media";
|
||||
"Scene.Profile.SegmentedControl.Posts" = "Posts";
|
||||
|
@ -238,7 +238,7 @@ tap the link to confirm your account.";
|
|||
"Scene.Register.Error.Item.Username" = "Username";
|
||||
"Scene.Register.Error.Reason.Accepted" = "%@ must be accepted";
|
||||
"Scene.Register.Error.Reason.Blank" = "%@ is required";
|
||||
"Scene.Register.Error.Reason.Blocked" = "%@ contains a disallowed e-mail provider";
|
||||
"Scene.Register.Error.Reason.Blocked" = "%@ contains a disallowed email provider";
|
||||
"Scene.Register.Error.Reason.Inclusion" = "%@ is not a supported value";
|
||||
"Scene.Register.Error.Reason.Invalid" = "%@ is invalid";
|
||||
"Scene.Register.Error.Reason.Reserved" = "%@ is a reserved keyword";
|
||||
|
@ -246,7 +246,7 @@ tap the link to confirm your account.";
|
|||
"Scene.Register.Error.Reason.TooLong" = "%@ is too long";
|
||||
"Scene.Register.Error.Reason.TooShort" = "%@ is too short";
|
||||
"Scene.Register.Error.Reason.Unreachable" = "%@ does not seem to exist";
|
||||
"Scene.Register.Error.Special.EmailInvalid" = "This is not a valid e-mail address";
|
||||
"Scene.Register.Error.Special.EmailInvalid" = "This is not a valid email address";
|
||||
"Scene.Register.Error.Special.PasswordTooShort" = "Password is too short (must be at least 8 characters)";
|
||||
"Scene.Register.Error.Special.UsernameInvalid" = "Username must only contain alphanumeric characters and underscores";
|
||||
"Scene.Register.Error.Special.UsernameTooLong" = "Username is too long (can’t be longer than 30 characters)";
|
||||
|
@ -271,16 +271,19 @@ tap the link to confirm your account.";
|
|||
"Scene.Search.Recommend.Accounts.Follow" = "Follow";
|
||||
"Scene.Search.Recommend.Accounts.Title" = "Accounts you might like";
|
||||
"Scene.Search.Recommend.ButtonText" = "See All";
|
||||
"Scene.Search.Recommend.HashTag.Description" = "Hashtags that are getting quite a bit of attention among people you follow";
|
||||
"Scene.Search.Recommend.HashTag.Description" = "Hashtags that are getting quite a bit of attention";
|
||||
"Scene.Search.Recommend.HashTag.PeopleTalking" = "%@ people are talking";
|
||||
"Scene.Search.Recommend.HashTag.Title" = "Trending in your timeline";
|
||||
"Scene.Search.Searchbar.Cancel" = "Cancel";
|
||||
"Scene.Search.Searchbar.Placeholder" = "Search hashtags and users";
|
||||
"Scene.Search.Recommend.HashTag.Title" = "Trending on Mastodon";
|
||||
"Scene.Search.SearchBar.Cancel" = "Cancel";
|
||||
"Scene.Search.SearchBar.Placeholder" = "Search hashtags and users";
|
||||
"Scene.Search.Searching.Clear" = "Clear";
|
||||
"Scene.Search.Searching.EmptyState.NoResults" = "No results";
|
||||
"Scene.Search.Searching.RecentSearch" = "Recent searches";
|
||||
"Scene.Search.Searching.Segment.All" = "All";
|
||||
"Scene.Search.Searching.Segment.Hashtags" = "Hashtags";
|
||||
"Scene.Search.Searching.Segment.People" = "People";
|
||||
"Scene.Search.Searching.Segment.Posts" = "Posts";
|
||||
"Scene.Search.Title" = "Search";
|
||||
"Scene.ServerPicker.Button.Category.Academia" = "academia";
|
||||
"Scene.ServerPicker.Button.Category.Activism" = "activism";
|
||||
"Scene.ServerPicker.Button.Category.All" = "All";
|
||||
|
@ -297,7 +300,7 @@ tap the link to confirm your account.";
|
|||
"Scene.ServerPicker.Button.Category.Tech" = "tech";
|
||||
"Scene.ServerPicker.Button.SeeLess" = "See Less";
|
||||
"Scene.ServerPicker.Button.SeeMore" = "See More";
|
||||
"Scene.ServerPicker.EmptyState.BadNetwork" = "Something went wrong while loading data. Check your internet connection.";
|
||||
"Scene.ServerPicker.EmptyState.BadNetwork" = "Something went wrong while loading the data. Check your internet connection.";
|
||||
"Scene.ServerPicker.EmptyState.FindingServers" = "Finding available servers...";
|
||||
"Scene.ServerPicker.EmptyState.NoResults" = "No results";
|
||||
"Scene.ServerPicker.Input.Placeholder" = "Find a server or join your own...";
|
||||
|
@ -318,11 +321,12 @@ any server.";
|
|||
"Scene.Settings.Section.Appearance.Dark" = "Always Dark";
|
||||
"Scene.Settings.Section.Appearance.Light" = "Always Light";
|
||||
"Scene.Settings.Section.Appearance.Title" = "Appearance";
|
||||
"Scene.Settings.Section.AppearanceSettings.DisableAvatarAnimation" = "Disable avatar animation";
|
||||
"Scene.Settings.Section.AppearanceSettings.TrueBlackDarkMode" = "True black Dark Mode";
|
||||
"Scene.Settings.Section.Boringzone.Privacy" = "Privacy Policy";
|
||||
"Scene.Settings.Section.Boringzone.Terms" = "Terms of Service";
|
||||
"Scene.Settings.Section.Boringzone.Title" = "The Boring zone";
|
||||
"Scene.Settings.Section.AppearanceSettings.DisableAvatarAnimation" = "Disable animated avatars";
|
||||
"Scene.Settings.Section.AppearanceSettings.TrueBlackDarkMode" = "True black dark mode";
|
||||
"Scene.Settings.Section.BoringZone.AccountSettings" = "Account settings";
|
||||
"Scene.Settings.Section.BoringZone.Privacy" = "Privacy Policy";
|
||||
"Scene.Settings.Section.BoringZone.Terms" = "Terms of Service";
|
||||
"Scene.Settings.Section.BoringZone.Title" = "The Boring Zone";
|
||||
"Scene.Settings.Section.Notifications.Boosts" = "Reblogs my post";
|
||||
"Scene.Settings.Section.Notifications.Favorites" = "Favorites my post";
|
||||
"Scene.Settings.Section.Notifications.Follows" = "Follows me";
|
||||
|
@ -333,11 +337,11 @@ any server.";
|
|||
"Scene.Settings.Section.Notifications.Trigger.Follower" = "a follower";
|
||||
"Scene.Settings.Section.Notifications.Trigger.Noone" = "no one";
|
||||
"Scene.Settings.Section.Notifications.Trigger.Title" = "Notify me when";
|
||||
"Scene.Settings.Section.Preference.Title" = "Preference";
|
||||
"Scene.Settings.Section.Preference.UsingDefaultBrowser" = "Using default browser open link";
|
||||
"Scene.Settings.Section.Spicyzone.Clear" = "Clear Media Cache";
|
||||
"Scene.Settings.Section.Spicyzone.Signout" = "Sign Out";
|
||||
"Scene.Settings.Section.Spicyzone.Title" = "The spicy zone";
|
||||
"Scene.Settings.Section.Preference.Title" = "Preferences";
|
||||
"Scene.Settings.Section.Preference.UsingDefaultBrowser" = "Use default browser to open links";
|
||||
"Scene.Settings.Section.SpicyZone.Clear" = "Clear Media Cache";
|
||||
"Scene.Settings.Section.SpicyZone.Signout" = "Sign Out";
|
||||
"Scene.Settings.Section.SpicyZone.Title" = "The Spicy Zone";
|
||||
"Scene.Settings.Title" = "Settings";
|
||||
"Scene.SuggestionAccount.FollowExplain" = "When you follow someone, you’ll see their posts in your home feed.";
|
||||
"Scene.SuggestionAccount.Title" = "Find People to Follow";
|
||||
|
|
|
@ -1,26 +1,26 @@
|
|||
"Common.Alerts.BlockDomain.BlockEntireDomain" = "Block entire domain";
|
||||
"Common.Alerts.BlockDomain.Title" = "Are you really, really sure you want to block the entire %@? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.";
|
||||
"Common.Alerts.CleanCache.Message" = "Successfully clean %@ cache.";
|
||||
"Common.Alerts.BlockDomain.BlockEntireDomain" = "Block Domain";
|
||||
"Common.Alerts.BlockDomain.Title" = "Are you really, really sure you want to block the entire %@? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain and any of your followers from that domain will be removed.";
|
||||
"Common.Alerts.CleanCache.Message" = "Successfully cleaned %@ cache.";
|
||||
"Common.Alerts.CleanCache.Title" = "Clean Cache";
|
||||
"Common.Alerts.Common.PleaseTryAgain" = "Please try again.";
|
||||
"Common.Alerts.Common.PleaseTryAgainLater" = "Please try again later.";
|
||||
"Common.Alerts.DeletePost.Delete" = "Delete";
|
||||
"Common.Alerts.DeletePost.Title" = "Are you sure you want to delete this post?";
|
||||
"Common.Alerts.DiscardPostContent.Message" = "Confirm discard composed post content.";
|
||||
"Common.Alerts.DiscardPostContent.Title" = "Discard Publish";
|
||||
"Common.Alerts.DiscardPostContent.Message" = "Confirm to discard composed post content.";
|
||||
"Common.Alerts.DiscardPostContent.Title" = "Discard Draft";
|
||||
"Common.Alerts.EditProfileFailure.Message" = "Cannot edit profile. Please try again.";
|
||||
"Common.Alerts.EditProfileFailure.Title" = "Edit Profile Error";
|
||||
"Common.Alerts.PublishPostFailure.AttchmentsMessage.MoreThanOneVideo" = "Cannot attach more than one video.";
|
||||
"Common.Alerts.PublishPostFailure.AttchmentsMessage.VideoAttachWithPhoto" = "Cannot attach a video to a status that already contains images.";
|
||||
"Common.Alerts.PublishPostFailure.AttchmentsMessage.VideoAttachWithPhoto" = "Cannot attach a video to a post that already contains images.";
|
||||
"Common.Alerts.PublishPostFailure.Message" = "Failed to publish the post.
|
||||
Please check your internet connection.";
|
||||
"Common.Alerts.PublishPostFailure.Title" = "Publish Failure";
|
||||
"Common.Alerts.SavePhotoFailure.Message" = "Please enable photo libaray access permission to save photo.";
|
||||
"Common.Alerts.SavePhotoFailure.Message" = "Please enable the photo library access permission to save the photo.";
|
||||
"Common.Alerts.SavePhotoFailure.Title" = "Save Photo Failure";
|
||||
"Common.Alerts.ServerError.Title" = "Server Error";
|
||||
"Common.Alerts.SignOut.Confirm" = "Sign Out";
|
||||
"Common.Alerts.SignOut.Message" = "Are you sure you want to sign out?";
|
||||
"Common.Alerts.SignOut.Title" = "Sign out";
|
||||
"Common.Alerts.SignOut.Title" = "Sign Out";
|
||||
"Common.Alerts.SignUpFailure.Title" = "Sign Up Failure";
|
||||
"Common.Alerts.VoteFailure.PollExpired" = "The poll has expired";
|
||||
"Common.Alerts.VoteFailure.Title" = "Vote Failure";
|
||||
|
@ -81,22 +81,22 @@ Please check your internet connection.";
|
|||
"Common.Controls.Keyboard.Common.SwitchToTab" = "Switch to %@";
|
||||
"Common.Controls.Keyboard.SegmentedControl.NextSection" = "Next Section";
|
||||
"Common.Controls.Keyboard.SegmentedControl.PreviousSection" = "Previous Section";
|
||||
"Common.Controls.Keyboard.Timeline.NextStatus" = "Next Status";
|
||||
"Common.Controls.Keyboard.Timeline.OpenAuthorProfile" = "Open Author Profile";
|
||||
"Common.Controls.Keyboard.Timeline.OpenRebloggerProfile" = "Open Reblogger Profile";
|
||||
"Common.Controls.Keyboard.Timeline.OpenStatus" = "Open Status";
|
||||
"Common.Controls.Keyboard.Timeline.NextStatus" = "Next Post";
|
||||
"Common.Controls.Keyboard.Timeline.OpenAuthorProfile" = "Open Author's Profile";
|
||||
"Common.Controls.Keyboard.Timeline.OpenRebloggerProfile" = "Open Reblogger's Profile";
|
||||
"Common.Controls.Keyboard.Timeline.OpenStatus" = "Open Post";
|
||||
"Common.Controls.Keyboard.Timeline.PreviewImage" = "Preview Image";
|
||||
"Common.Controls.Keyboard.Timeline.PreviousStatus" = "Previous Status";
|
||||
"Common.Controls.Keyboard.Timeline.ReplyStatus" = "Reply Status";
|
||||
"Common.Controls.Keyboard.Timeline.PreviousStatus" = "Previous Post";
|
||||
"Common.Controls.Keyboard.Timeline.ReplyStatus" = "Reply to Post";
|
||||
"Common.Controls.Keyboard.Timeline.ToggleContentWarning" = "Toggle Content Warning";
|
||||
"Common.Controls.Keyboard.Timeline.ToggleFavorite" = "Toggle Status Favorite";
|
||||
"Common.Controls.Keyboard.Timeline.ToggleReblog" = "Toggle Status Reblog";
|
||||
"Common.Controls.Keyboard.Timeline.ToggleFavorite" = "Toggle Favorite on Post";
|
||||
"Common.Controls.Keyboard.Timeline.ToggleReblog" = "Toggle Reblog on Post";
|
||||
"Common.Controls.Status.Actions.Favorite" = "Favorite";
|
||||
"Common.Controls.Status.Actions.Menu" = "Menu";
|
||||
"Common.Controls.Status.Actions.Reblog" = "Reblog";
|
||||
"Common.Controls.Status.Actions.Reply" = "Reply";
|
||||
"Common.Controls.Status.Actions.Unfavorite" = "Unfavorite";
|
||||
"Common.Controls.Status.Actions.Unreblog" = "Unreblog";
|
||||
"Common.Controls.Status.Actions.Unreblog" = "Undo reblog";
|
||||
"Common.Controls.Status.ContentWarning" = "Content Warning";
|
||||
"Common.Controls.Status.MediaContentWarning" = "Tap anywhere to reveal";
|
||||
"Common.Controls.Status.Poll.Closed" = "Closed";
|
||||
|
@ -120,44 +120,44 @@ Please check your internet connection.";
|
|||
"Common.Controls.Timeline.Accessibility.CountReblogs" = "%@ reblogs";
|
||||
"Common.Controls.Timeline.Accessibility.CountReplies" = "%@ replies";
|
||||
"Common.Controls.Timeline.Filtered" = "Filtered";
|
||||
"Common.Controls.Timeline.Header.BlockedWarning" = "You can’t view this’s profile
|
||||
"Common.Controls.Timeline.Header.BlockedWarning" = "You can’t view this user’s profile
|
||||
until they unblock you.";
|
||||
"Common.Controls.Timeline.Header.BlockingWarning" = "You can’t view this profile
|
||||
"Common.Controls.Timeline.Header.BlockingWarning" = "You can’t view this user's profile
|
||||
until you unblock them.
|
||||
Your account looks like this to them.";
|
||||
"Common.Controls.Timeline.Header.NoStatusFound" = "No Status Found";
|
||||
"Common.Controls.Timeline.Header.SuspendedWarning" = "This account has been suspended.";
|
||||
Your profile looks like this to them.";
|
||||
"Common.Controls.Timeline.Header.NoStatusFound" = "No Post Found";
|
||||
"Common.Controls.Timeline.Header.SuspendedWarning" = "This user has been suspended.";
|
||||
"Common.Controls.Timeline.Header.UserBlockedWarning" = "You can’t view %@’s profile
|
||||
until they unblock you.";
|
||||
"Common.Controls.Timeline.Header.UserBlockingWarning" = "You can’t view %@’s profile
|
||||
until you unblock them.
|
||||
Your account looks like this to them.";
|
||||
Your profile looks like this to them.";
|
||||
"Common.Controls.Timeline.Header.UserSuspendedWarning" = "%@’s account has been suspended.";
|
||||
"Common.Controls.Timeline.Loader.LoadMissingPosts" = "Load missing posts";
|
||||
"Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts...";
|
||||
"Common.Controls.Timeline.Loader.ShowMoreReplies" = "Show more replies";
|
||||
"Common.Controls.Timeline.Timestamp.Now" = "Now";
|
||||
"Common.Controls.Timeline.Timestamp.TimeAgo" = "%@ ago";
|
||||
"Scene.Compose.Accessibility.AppendAttachment" = "Append attachment";
|
||||
"Scene.Compose.Accessibility.AppendPoll" = "Append poll";
|
||||
"Scene.Compose.Accessibility.CustomEmojiPicker" = "Custom emoji picker";
|
||||
"Scene.Compose.Accessibility.DisableContentWarning" = "Disable content warning";
|
||||
"Scene.Compose.Accessibility.EnableContentWarning" = "Enable content warning";
|
||||
"Scene.Compose.Accessibility.AppendAttachment" = "Add Attachment";
|
||||
"Scene.Compose.Accessibility.AppendPoll" = "Add Poll";
|
||||
"Scene.Compose.Accessibility.CustomEmojiPicker" = "Custom Emoji Picker";
|
||||
"Scene.Compose.Accessibility.DisableContentWarning" = "Disable Content Warning";
|
||||
"Scene.Compose.Accessibility.EnableContentWarning" = "Enable Content Warning";
|
||||
"Scene.Compose.Accessibility.InputLimitExceedsCount" = "Input limit exceeds %ld";
|
||||
"Scene.Compose.Accessibility.InputLimitRemainsCount" = "Input limit remains %ld";
|
||||
"Scene.Compose.Accessibility.PostVisibilityMenu" = "Post visibility menu";
|
||||
"Scene.Compose.Accessibility.RemovePoll" = "Remove poll";
|
||||
"Scene.Compose.Accessibility.PostVisibilityMenu" = "Post Visibility Menu";
|
||||
"Scene.Compose.Accessibility.RemovePoll" = "Remove Poll";
|
||||
"Scene.Compose.Attachment.AttachmentBroken" = "This %@ is broken and can’t be
|
||||
uploaded to Mastodon.";
|
||||
"Scene.Compose.Attachment.DescriptionPhoto" = "Describe photo for low vision people...";
|
||||
"Scene.Compose.Attachment.DescriptionVideo" = "Describe what’s happening for low vision people...";
|
||||
"Scene.Compose.Attachment.DescriptionPhoto" = "Describe the photo for the visually-impaired...";
|
||||
"Scene.Compose.Attachment.DescriptionVideo" = "Describe the video for the visually-impaired...";
|
||||
"Scene.Compose.Attachment.Photo" = "photo";
|
||||
"Scene.Compose.Attachment.Video" = "video";
|
||||
"Scene.Compose.AutoComplete.SpaceToAdd" = "Space to add";
|
||||
"Scene.Compose.ComposeAction" = "Publish";
|
||||
"Scene.Compose.ContentInputPlaceholder" = "Type or paste what’s on your mind";
|
||||
"Scene.Compose.ContentWarning.Placeholder" = "Write an accurate warning here...";
|
||||
"Scene.Compose.Keyboard.AppendAttachmentEntry" = "Append Attachment - %@";
|
||||
"Scene.Compose.Keyboard.AppendAttachmentEntry" = "Add Attachment - %@";
|
||||
"Scene.Compose.Keyboard.DiscardPost" = "Discard Post";
|
||||
"Scene.Compose.Keyboard.PublishPost" = "Publish Post";
|
||||
"Scene.Compose.Keyboard.SelectVisibilityEntry" = "Select Visibility - %@";
|
||||
|
@ -202,10 +202,10 @@ tap the link to confirm your account.";
|
|||
"Scene.HomeTimeline.Title" = "Home";
|
||||
"Scene.Notification.Action.Favourite" = "favorited your post";
|
||||
"Scene.Notification.Action.Follow" = "followed you";
|
||||
"Scene.Notification.Action.FollowRequest" = "request to follow you";
|
||||
"Scene.Notification.Action.FollowRequest" = "requested to follow you";
|
||||
"Scene.Notification.Action.Mention" = "mentioned you";
|
||||
"Scene.Notification.Action.Poll" = "Your poll has ended";
|
||||
"Scene.Notification.Action.Reblog" = "rebloged your post";
|
||||
"Scene.Notification.Action.Reblog" = "reblogged your post";
|
||||
"Scene.Notification.Keyobard.ShowEverything" = "Show Everything";
|
||||
"Scene.Notification.Keyobard.ShowMentions" = "Show Mentions";
|
||||
"Scene.Notification.Title.Everything" = "Everything";
|
||||
|
@ -222,9 +222,9 @@ tap the link to confirm your account.";
|
|||
"Scene.Profile.Fields.AddRow" = "Add Row";
|
||||
"Scene.Profile.Fields.Placeholder.Content" = "Content";
|
||||
"Scene.Profile.Fields.Placeholder.Label" = "Label";
|
||||
"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message" = "Confirm unblock %@";
|
||||
"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message" = "Confirm to unblock %@";
|
||||
"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title" = "Unblock Account";
|
||||
"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Confirm unmute %@";
|
||||
"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Confirm to unmute %@";
|
||||
"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Title" = "Unmute Account";
|
||||
"Scene.Profile.SegmentedControl.Media" = "Media";
|
||||
"Scene.Profile.SegmentedControl.Posts" = "Posts";
|
||||
|
@ -238,7 +238,7 @@ tap the link to confirm your account.";
|
|||
"Scene.Register.Error.Item.Username" = "Username";
|
||||
"Scene.Register.Error.Reason.Accepted" = "%@ must be accepted";
|
||||
"Scene.Register.Error.Reason.Blank" = "%@ is required";
|
||||
"Scene.Register.Error.Reason.Blocked" = "%@ contains a disallowed e-mail provider";
|
||||
"Scene.Register.Error.Reason.Blocked" = "%@ contains a disallowed email provider";
|
||||
"Scene.Register.Error.Reason.Inclusion" = "%@ is not a supported value";
|
||||
"Scene.Register.Error.Reason.Invalid" = "%@ is invalid";
|
||||
"Scene.Register.Error.Reason.Reserved" = "%@ is a reserved keyword";
|
||||
|
@ -246,7 +246,7 @@ tap the link to confirm your account.";
|
|||
"Scene.Register.Error.Reason.TooLong" = "%@ is too long";
|
||||
"Scene.Register.Error.Reason.TooShort" = "%@ is too short";
|
||||
"Scene.Register.Error.Reason.Unreachable" = "%@ does not seem to exist";
|
||||
"Scene.Register.Error.Special.EmailInvalid" = "This is not a valid e-mail address";
|
||||
"Scene.Register.Error.Special.EmailInvalid" = "This is not a valid email address";
|
||||
"Scene.Register.Error.Special.PasswordTooShort" = "Password is too short (must be at least 8 characters)";
|
||||
"Scene.Register.Error.Special.UsernameInvalid" = "Username must only contain alphanumeric characters and underscores";
|
||||
"Scene.Register.Error.Special.UsernameTooLong" = "Username is too long (can’t be longer than 30 characters)";
|
||||
|
@ -271,16 +271,19 @@ tap the link to confirm your account.";
|
|||
"Scene.Search.Recommend.Accounts.Follow" = "Follow";
|
||||
"Scene.Search.Recommend.Accounts.Title" = "Accounts you might like";
|
||||
"Scene.Search.Recommend.ButtonText" = "See All";
|
||||
"Scene.Search.Recommend.HashTag.Description" = "Hashtags that are getting quite a bit of attention among people you follow";
|
||||
"Scene.Search.Recommend.HashTag.Description" = "Hashtags that are getting quite a bit of attention";
|
||||
"Scene.Search.Recommend.HashTag.PeopleTalking" = "%@ people are talking";
|
||||
"Scene.Search.Recommend.HashTag.Title" = "Trending in your timeline";
|
||||
"Scene.Search.Searchbar.Cancel" = "Cancel";
|
||||
"Scene.Search.Searchbar.Placeholder" = "Search hashtags and users";
|
||||
"Scene.Search.Recommend.HashTag.Title" = "Trending on Mastodon";
|
||||
"Scene.Search.SearchBar.Cancel" = "Cancel";
|
||||
"Scene.Search.SearchBar.Placeholder" = "Search hashtags and users";
|
||||
"Scene.Search.Searching.Clear" = "Clear";
|
||||
"Scene.Search.Searching.EmptyState.NoResults" = "No results";
|
||||
"Scene.Search.Searching.RecentSearch" = "Recent searches";
|
||||
"Scene.Search.Searching.Segment.All" = "All";
|
||||
"Scene.Search.Searching.Segment.Hashtags" = "Hashtags";
|
||||
"Scene.Search.Searching.Segment.People" = "People";
|
||||
"Scene.Search.Searching.Segment.Posts" = "Posts";
|
||||
"Scene.Search.Title" = "Search";
|
||||
"Scene.ServerPicker.Button.Category.Academia" = "academia";
|
||||
"Scene.ServerPicker.Button.Category.Activism" = "activism";
|
||||
"Scene.ServerPicker.Button.Category.All" = "All";
|
||||
|
@ -297,7 +300,7 @@ tap the link to confirm your account.";
|
|||
"Scene.ServerPicker.Button.Category.Tech" = "tech";
|
||||
"Scene.ServerPicker.Button.SeeLess" = "See Less";
|
||||
"Scene.ServerPicker.Button.SeeMore" = "See More";
|
||||
"Scene.ServerPicker.EmptyState.BadNetwork" = "Something went wrong while loading data. Check your internet connection.";
|
||||
"Scene.ServerPicker.EmptyState.BadNetwork" = "Something went wrong while loading the data. Check your internet connection.";
|
||||
"Scene.ServerPicker.EmptyState.FindingServers" = "Finding available servers...";
|
||||
"Scene.ServerPicker.EmptyState.NoResults" = "No results";
|
||||
"Scene.ServerPicker.Input.Placeholder" = "Find a server or join your own...";
|
||||
|
@ -318,11 +321,12 @@ any server.";
|
|||
"Scene.Settings.Section.Appearance.Dark" = "Always Dark";
|
||||
"Scene.Settings.Section.Appearance.Light" = "Always Light";
|
||||
"Scene.Settings.Section.Appearance.Title" = "Appearance";
|
||||
"Scene.Settings.Section.AppearanceSettings.DisableAvatarAnimation" = "Disable avatar animation";
|
||||
"Scene.Settings.Section.AppearanceSettings.TrueBlackDarkMode" = "True black Dark Mode";
|
||||
"Scene.Settings.Section.Boringzone.Privacy" = "Privacy Policy";
|
||||
"Scene.Settings.Section.Boringzone.Terms" = "Terms of Service";
|
||||
"Scene.Settings.Section.Boringzone.Title" = "The Boring zone";
|
||||
"Scene.Settings.Section.AppearanceSettings.DisableAvatarAnimation" = "Disable animated avatars";
|
||||
"Scene.Settings.Section.AppearanceSettings.TrueBlackDarkMode" = "True black dark mode";
|
||||
"Scene.Settings.Section.BoringZone.AccountSettings" = "Account settings";
|
||||
"Scene.Settings.Section.BoringZone.Privacy" = "Privacy Policy";
|
||||
"Scene.Settings.Section.BoringZone.Terms" = "Terms of Service";
|
||||
"Scene.Settings.Section.BoringZone.Title" = "The Boring Zone";
|
||||
"Scene.Settings.Section.Notifications.Boosts" = "Reblogs my post";
|
||||
"Scene.Settings.Section.Notifications.Favorites" = "Favorites my post";
|
||||
"Scene.Settings.Section.Notifications.Follows" = "Follows me";
|
||||
|
@ -333,11 +337,11 @@ any server.";
|
|||
"Scene.Settings.Section.Notifications.Trigger.Follower" = "a follower";
|
||||
"Scene.Settings.Section.Notifications.Trigger.Noone" = "no one";
|
||||
"Scene.Settings.Section.Notifications.Trigger.Title" = "Notify me when";
|
||||
"Scene.Settings.Section.Preference.Title" = "Preference";
|
||||
"Scene.Settings.Section.Preference.UsingDefaultBrowser" = "Using default browser open link";
|
||||
"Scene.Settings.Section.Spicyzone.Clear" = "Clear Media Cache";
|
||||
"Scene.Settings.Section.Spicyzone.Signout" = "Sign Out";
|
||||
"Scene.Settings.Section.Spicyzone.Title" = "The spicy zone";
|
||||
"Scene.Settings.Section.Preference.Title" = "Preferences";
|
||||
"Scene.Settings.Section.Preference.UsingDefaultBrowser" = "Use default browser to open links";
|
||||
"Scene.Settings.Section.SpicyZone.Clear" = "Clear Media Cache";
|
||||
"Scene.Settings.Section.SpicyZone.Signout" = "Sign Out";
|
||||
"Scene.Settings.Section.SpicyZone.Title" = "The Spicy Zone";
|
||||
"Scene.Settings.Title" = "Settings";
|
||||
"Scene.SuggestionAccount.FollowExplain" = "When you follow someone, you’ll see their posts in your home feed.";
|
||||
"Scene.SuggestionAccount.Title" = "Find People to Follow";
|
||||
|
|
|
@ -242,8 +242,6 @@ extension ComposeViewController {
|
|||
}
|
||||
return margin
|
||||
}()
|
||||
|
||||
// update keyboard background color
|
||||
|
||||
guard isShow, state == .dock else {
|
||||
self.tableView.contentInset.bottom = extraMargin
|
||||
|
@ -591,19 +589,6 @@ extension ComposeViewController {
|
|||
|
||||
private func updateKeyboardBackground(isKeyboardDisplay: Bool) {
|
||||
composeToolbarBackgroundView.backgroundColor = Asset.Scene.Compose.toolbarBackground.color
|
||||
|
||||
// Deprecated: not works for new Dark Mode color
|
||||
// guard isKeyboardDisplay else {
|
||||
// composeToolbarBackgroundView.backgroundColor = Asset.Scene.Compose.toolbarBackground.color
|
||||
// return
|
||||
// }
|
||||
// composeToolbarBackgroundView.backgroundColor = UIColor(dynamicProvider: { traitCollection -> UIColor in
|
||||
// // avoid elevated color
|
||||
// switch traitCollection.userInterfaceStyle {
|
||||
// case .light: return .white
|
||||
// default: return .black
|
||||
// }
|
||||
// })
|
||||
}
|
||||
|
||||
private func setupBackgroundColor(theme: Theme) {
|
||||
|
@ -946,7 +931,7 @@ extension ComposeViewController: UICollectionViewDelegate {
|
|||
extension ComposeViewController: UIAdaptivePresentationControllerDelegate {
|
||||
|
||||
func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle {
|
||||
return .fullScreen
|
||||
return .overFullScreen
|
||||
//return traitCollection.userInterfaceIdiom == .pad ? .formSheet : .automatic
|
||||
}
|
||||
|
||||
|
|
|
@ -83,6 +83,12 @@ extension HashtagTimelineViewController: StatusProvider {
|
|||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func statusObjectItems(indexPaths: [IndexPath]) -> [StatusObjectItem] {
|
||||
guard let diffableDataSource = self.viewModel.diffableDataSource else { return [] }
|
||||
let items = indexPaths.compactMap { diffableDataSource.itemIdentifier(for: $0)?.statusObjectItem }
|
||||
return items
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -83,6 +83,12 @@ extension HomeTimelineViewController: StatusProvider {
|
|||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func statusObjectItems(indexPaths: [IndexPath]) -> [StatusObjectItem] {
|
||||
guard let diffableDataSource = self.viewModel.diffableDataSource else { return [] }
|
||||
let items = indexPaths.compactMap { diffableDataSource.itemIdentifier(for: $0)?.statusObjectItem }
|
||||
return items
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -316,7 +316,9 @@ extension HomeTimelineViewController {
|
|||
}
|
||||
|
||||
@objc private func manuallySearchButtonPressed(_ sender: UIButton) {
|
||||
coordinator.switchToTabBar(tab: .search)
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
let searchDetailViewModel = SearchDetailViewModel()
|
||||
coordinator.present(scene: .searchDetail(viewModel: searchDetailViewModel), from: self, transition: .modal(animated: true, completion: nil))
|
||||
}
|
||||
|
||||
@objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) {
|
||||
|
@ -431,6 +433,10 @@ extension HomeTimelineViewController: UITableViewDataSourcePrefetching {
|
|||
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
|
||||
aspectTableView(tableView, prefetchRowsAt: indexPaths)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
|
||||
aspectTableView(tableView, cancelPrefetchingForRowsAt: indexPaths)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate
|
||||
|
|
|
@ -101,14 +101,13 @@ extension MainTabBarController {
|
|||
delegate = self
|
||||
|
||||
view.backgroundColor = ThemeService.shared.currentTheme.value.systemBackgroundColor
|
||||
// ThemeService.shared.currentTheme
|
||||
// .receive(on: RunLoop.main)
|
||||
// .sink { [weak self] theme in
|
||||
// guard let self = self else { return }
|
||||
// // fix tab bar not update color issue
|
||||
// self.tabBar.backgroundColor = theme.tabBarBackgroundColor
|
||||
// }
|
||||
// .store(in: &disposeBag)
|
||||
ThemeService.shared.currentTheme
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] theme in
|
||||
guard let self = self else { return }
|
||||
self.view.backgroundColor = theme.tabBarBackgroundColor
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
let tabs = Tab.allCases
|
||||
let viewControllers: [UIViewController] = tabs.map { tab in
|
||||
|
@ -189,6 +188,10 @@ extension MainTabBarController {
|
|||
self.coordinator.present(scene: .thread(viewModel: threadViewModel), from: nil, transition: .show)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
#if DEBUG
|
||||
// selectedIndex = 1
|
||||
#endif
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -61,5 +61,10 @@ extension NotificationViewController: StatusProvider {
|
|||
return []
|
||||
}
|
||||
|
||||
func statusObjectItems(indexPaths: [IndexPath]) -> [StatusObjectItem] {
|
||||
guard let diffableDataSource = self.viewModel.diffableDataSource else { return [] }
|
||||
let items = indexPaths.compactMap { diffableDataSource.itemIdentifier(for: $0)?.statusObjectItem }
|
||||
return items
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -83,6 +83,12 @@ extension FavoriteViewController: StatusProvider {
|
|||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func statusObjectItems(indexPaths: [IndexPath]) -> [StatusObjectItem] {
|
||||
guard let diffableDataSource = self.viewModel.diffableDataSource else { return [] }
|
||||
let items = indexPaths.compactMap { diffableDataSource.itemIdentifier(for: $0)?.statusObjectItem }
|
||||
return items
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -83,6 +83,12 @@ extension UserTimelineViewController: StatusProvider {
|
|||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func statusObjectItems(indexPaths: [IndexPath]) -> [StatusObjectItem] {
|
||||
guard let diffableDataSource = self.viewModel.diffableDataSource else { return [] }
|
||||
let items = indexPaths.compactMap { diffableDataSource.itemIdentifier(for: $0)?.statusObjectItem }
|
||||
return items
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -179,8 +179,8 @@ extension UserTimelineViewModel.State {
|
|||
super.didEnter(from: previousState)
|
||||
guard let viewModel = viewModel, let _ = stateMachine else { return }
|
||||
|
||||
// trigger data source update
|
||||
viewModel.statusFetchedResultsController.objectIDs.value = viewModel.statusFetchedResultsController.objectIDs.value
|
||||
// trigger data source update. otherwise, spinner always display
|
||||
viewModel.isSuspended.value = viewModel.isSuspended.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,11 +24,12 @@ final class UserTimelineViewModel {
|
|||
let queryFilter: CurrentValueSubject<QueryFilter, Never>
|
||||
let statusFetchedResultsController: StatusFetchedResultsController
|
||||
var cellFrameCache = NSCache<NSNumber, NSValue>()
|
||||
|
||||
|
||||
let isBlocking = CurrentValueSubject<Bool, Never>(false)
|
||||
let isBlockedBy = CurrentValueSubject<Bool, Never>(false)
|
||||
let isSuspended = CurrentValueSubject<Bool, Never>(false)
|
||||
let userDisplayName = CurrentValueSubject<String?, Never>(nil) // for suspended prompt label
|
||||
var dataSourceDidUpdate = PassthroughSubject<Void, Never>()
|
||||
|
||||
// output
|
||||
var diffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>?
|
||||
|
@ -77,9 +78,13 @@ final class UserTimelineViewModel {
|
|||
var snapshot = NSDiffableDataSourceSnapshot<StatusSection, Item>()
|
||||
snapshot.appendSections([.main])
|
||||
|
||||
var animatingDifferences = true
|
||||
defer {
|
||||
// not animate when empty items fix loader first appear layout issue
|
||||
diffableDataSource.apply(snapshot, animatingDifferences: !items.isEmpty)
|
||||
diffableDataSource.apply(snapshot, animatingDifferences: animatingDifferences) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.dataSourceDidUpdate.send()
|
||||
}
|
||||
}
|
||||
|
||||
let name = self.userDisplayName.value
|
||||
|
@ -125,7 +130,8 @@ final class UserTimelineViewModel {
|
|||
case is State.Reloading, is State.Loading, is State.Idle, is State.Fail:
|
||||
snapshot.appendItems([.bottomLoader], toSection: .main)
|
||||
case is State.NoMore:
|
||||
break
|
||||
snapshot.appendItems([.emptyBottomLoader], toSection: .main)
|
||||
animatingDifferences = false
|
||||
// TODO: handle other states
|
||||
default:
|
||||
break
|
||||
|
|
|
@ -83,6 +83,12 @@ extension PublicTimelineViewController: StatusProvider {
|
|||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func statusObjectItems(indexPaths: [IndexPath]) -> [StatusObjectItem] {
|
||||
guard let diffableDataSource = self.viewModel.diffableDataSource else { return [] }
|
||||
let items = indexPaths.compactMap { diffableDataSource.itemIdentifier(for: $0)?.statusObjectItem }
|
||||
return items
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -62,12 +62,19 @@ extension SearchViewController: UICollectionViewDelegate {
|
|||
case self.accountsCollectionView:
|
||||
guard let diffableDataSource = viewModel.accountDiffableDataSource else { return }
|
||||
guard let accountObjectID = diffableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||
let user = context.managedObjectContext.object(with: accountObjectID) as! MastodonUser
|
||||
viewModel.accountCollectionViewItemDidSelected(mastodonUser: user, from: self)
|
||||
let mastodonUser = context.managedObjectContext.object(with: accountObjectID) as! MastodonUser
|
||||
let viewModel = ProfileViewModel(context: context, optionalMastodonUser: mastodonUser)
|
||||
DispatchQueue.main.async {
|
||||
self.coordinator.present(scene: .profile(viewModel: viewModel), from: self, transition: .show)
|
||||
}
|
||||
case self.hashtagCollectionView:
|
||||
guard let diffableDataSource = viewModel.hashtagDiffableDataSource else { return }
|
||||
guard let hashtag = diffableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||
viewModel.hashtagCollectionViewItemDidSelected(hashtag: hashtag, from: self)
|
||||
let (tagInCoreData, _) = APIService.CoreData.createOrMergeTag(into: context.managedObjectContext, entity: hashtag)
|
||||
let viewModel = HashtagTimelineViewModel(context: context, hashtag: tagInCoreData.name)
|
||||
DispatchQueue.main.async {
|
||||
self.coordinator.present(scene: .hashtagTimeline(viewModel: viewModel), from: self, transition: .show)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
|
@ -0,0 +1,207 @@
|
|||
//
|
||||
// SearchViewController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/3/31.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import Combine
|
||||
import GameplayKit
|
||||
import MastodonSDK
|
||||
import UIKit
|
||||
|
||||
final class SearchViewController: UIViewController, NeedsDependency {
|
||||
|
||||
let logger = Logger(subsystem: "Search", category: "UI")
|
||||
|
||||
public static var hashtagCardHeight: CGFloat {
|
||||
get {
|
||||
if UIScreen.main.bounds.size.height > 736 {
|
||||
return 186
|
||||
}
|
||||
return 130
|
||||
}
|
||||
}
|
||||
|
||||
public static var hashtagPeopleTalkingLabelTop: CGFloat {
|
||||
get {
|
||||
if UIScreen.main.bounds.size.height > 736 {
|
||||
return 18
|
||||
}
|
||||
return 6
|
||||
}
|
||||
}
|
||||
public static let accountCardHeight = 202
|
||||
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
|
||||
var searchTransitionController = SearchTransitionController()
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
private(set) lazy var viewModel = SearchViewModel(context: context, coordinator: coordinator)
|
||||
|
||||
// recommend
|
||||
let scrollView: UIScrollView = {
|
||||
let scrollView = UIScrollView()
|
||||
scrollView.showsVerticalScrollIndicator = false
|
||||
scrollView.alwaysBounceVertical = true
|
||||
scrollView.clipsToBounds = false
|
||||
return scrollView
|
||||
}()
|
||||
|
||||
let stackView: UIStackView = {
|
||||
let stackView = UIStackView()
|
||||
stackView.axis = .vertical
|
||||
stackView.distribution = .fill
|
||||
return stackView
|
||||
}()
|
||||
|
||||
let hashtagCollectionView: UICollectionView = {
|
||||
let flowLayout = UICollectionViewFlowLayout()
|
||||
flowLayout.scrollDirection = .horizontal
|
||||
let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout)
|
||||
view.backgroundColor = .clear
|
||||
view.showsHorizontalScrollIndicator = false
|
||||
view.showsVerticalScrollIndicator = false
|
||||
view.layer.masksToBounds = false
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
return view
|
||||
}()
|
||||
|
||||
let accountsCollectionView: UICollectionView = {
|
||||
let flowLayout = UICollectionViewFlowLayout()
|
||||
flowLayout.scrollDirection = .horizontal
|
||||
let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout)
|
||||
view.backgroundColor = .clear
|
||||
view.showsHorizontalScrollIndicator = false
|
||||
view.showsVerticalScrollIndicator = false
|
||||
view.layer.masksToBounds = false
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
return view
|
||||
}()
|
||||
|
||||
let searchBarTapPublisher = PassthroughSubject<Void, Never>()
|
||||
|
||||
}
|
||||
|
||||
extension SearchViewController {
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
setupBackgroundColor(theme: ThemeService.shared.currentTheme.value)
|
||||
ThemeService.shared.currentTheme
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] theme in
|
||||
guard let self = self else { return }
|
||||
self.setupBackgroundColor(theme: theme)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
title = L10n.Scene.Search.title
|
||||
|
||||
setupSearchBar()
|
||||
setupScrollView()
|
||||
setupHashTagCollectionView()
|
||||
setupAccountsCollectionView()
|
||||
setupDataSource()
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
viewModel.viewDidAppeared.send()
|
||||
}
|
||||
}
|
||||
|
||||
extension SearchViewController {
|
||||
private func setupBackgroundColor(theme: Theme) {
|
||||
view.backgroundColor = theme.systemGroupedBackgroundColor
|
||||
}
|
||||
|
||||
private func setupSearchBar() {
|
||||
let searchBar = UISearchBar()
|
||||
searchBar.placeholder = L10n.Scene.Search.SearchBar.placeholder
|
||||
searchBar.delegate = self
|
||||
navigationItem.titleView = searchBar
|
||||
|
||||
searchBarTapPublisher
|
||||
.throttle(for: 0.5, scheduler: DispatchQueue.main, latest: false)
|
||||
.sink { [weak self] in
|
||||
guard let self = self else { return }
|
||||
// push to search detail
|
||||
let searchDetailViewModel = SearchDetailViewModel()
|
||||
searchDetailViewModel.needsBecomeFirstResponder = true
|
||||
self.navigationController?.delegate = self.searchTransitionController
|
||||
self.coordinator.present(scene: .searchDetail(viewModel: searchDetailViewModel), from: self, transition: .customPush)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
private func setupScrollView() {
|
||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
// scrollView
|
||||
view.addSubview(scrollView)
|
||||
NSLayoutConstraint.activate([
|
||||
scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
view.trailingAnchor.constraint(equalTo: scrollView.frameLayoutGuide.trailingAnchor),
|
||||
scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
scrollView.frameLayoutGuide.widthAnchor.constraint(equalTo: scrollView.contentLayoutGuide.widthAnchor),
|
||||
])
|
||||
|
||||
// stack view
|
||||
scrollView.addSubview(stackView)
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
|
||||
stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
|
||||
stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
|
||||
stackView.widthAnchor.constraint(equalTo: scrollView.contentLayoutGuide.widthAnchor),
|
||||
scrollView.contentLayoutGuide.bottomAnchor.constraint(equalTo: stackView.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
private func setupDataSource() {
|
||||
viewModel.hashtagDiffableDataSource = RecommendHashTagSection.collectionViewDiffableDataSource(for: hashtagCollectionView)
|
||||
viewModel.accountDiffableDataSource = RecommendAccountSection.collectionViewDiffableDataSource(for: accountsCollectionView, delegate: self, managedObjectContext: context.managedObjectContext)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UISearchBarDelegate
|
||||
extension SearchViewController: UISearchBarDelegate {
|
||||
func searchBarShouldBeginEditing(_ searchBar: UISearchBar) -> Bool {
|
||||
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
searchBarTapPublisher.send()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK - UISearchControllerDelegate
|
||||
extension SearchViewController: UISearchControllerDelegate {
|
||||
func willDismissSearchController(_ searchController: UISearchController) {
|
||||
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
|
||||
searchController.isActive = true
|
||||
}
|
||||
func didPresentSearchController(_ searchController: UISearchController) {
|
||||
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(SwiftUI) && DEBUG
|
||||
import SwiftUI
|
||||
|
||||
struct SearchViewController_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
UIViewControllerPreview {
|
||||
let viewController = SearchViewController()
|
||||
return viewController
|
||||
}
|
||||
.previewLayout(.fixed(width: 375, height: 800))
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
|
@ -0,0 +1,144 @@
|
|||
//
|
||||
// SearchViewModel.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/3/31.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import Foundation
|
||||
import GameplayKit
|
||||
import MastodonSDK
|
||||
import OSLog
|
||||
import UIKit
|
||||
|
||||
final class SearchViewModel: NSObject {
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
// input
|
||||
let context: AppContext
|
||||
weak var coordinator: SceneCoordinator!
|
||||
|
||||
let currentMastodonUser = CurrentValueSubject<MastodonUser?, Never>(nil)
|
||||
let viewDidAppeared = PassthroughSubject<Void, Never>()
|
||||
|
||||
// output
|
||||
|
||||
// var recommendHashTags = [Mastodon.Entity.Tag]()
|
||||
var recommendAccounts = [NSManagedObjectID]()
|
||||
var recommendAccountsFallback = PassthroughSubject<Void, Never>()
|
||||
|
||||
var hashtagDiffableDataSource: UICollectionViewDiffableDataSource<RecommendHashTagSection, Mastodon.Entity.Tag>?
|
||||
var accountDiffableDataSource: UICollectionViewDiffableDataSource<RecommendAccountSection, NSManagedObjectID>?
|
||||
|
||||
init(context: AppContext, coordinator: SceneCoordinator) {
|
||||
self.coordinator = coordinator
|
||||
self.context = context
|
||||
super.init()
|
||||
|
||||
Publishers.CombineLatest(
|
||||
context.authenticationService.activeMastodonAuthenticationBox,
|
||||
viewDidAppeared
|
||||
)
|
||||
.compactMap { activeMastodonAuthenticationBox, _ -> AuthenticationService.MastodonAuthenticationBox? in
|
||||
return activeMastodonAuthenticationBox
|
||||
}
|
||||
.throttle(for: 1, scheduler: DispatchQueue.main, latest: false)
|
||||
.flatMap { box in
|
||||
context.apiService.recommendTrends(domain: box.domain, query: nil)
|
||||
.map { response in Result<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, Error> { response } }
|
||||
.catch { error in Just(Result<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, Error> { throw error }) }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
switch result {
|
||||
case .success(let response):
|
||||
guard let dataSource = self.hashtagDiffableDataSource else { return }
|
||||
var snapshot = NSDiffableDataSourceSnapshot<RecommendHashTagSection, Mastodon.Entity.Tag>()
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(response.value, toSection: .main)
|
||||
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
|
||||
case .failure(let error):
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
Publishers.CombineLatest(
|
||||
context.authenticationService.activeMastodonAuthenticationBox,
|
||||
viewDidAppeared
|
||||
)
|
||||
.compactMap { activeMastodonAuthenticationBox, _ -> AuthenticationService.MastodonAuthenticationBox? in
|
||||
return activeMastodonAuthenticationBox
|
||||
}
|
||||
.throttle(for: 1, scheduler: DispatchQueue.main, latest: false)
|
||||
.flatMap { box -> AnyPublisher<Result<[Mastodon.Entity.Account.ID], Error>, Never> in
|
||||
context.apiService.suggestionAccountV2(domain: box.domain, query: nil, mastodonAuthenticationBox: box)
|
||||
.map { response in Result<[Mastodon.Entity.Account.ID], Error> { response.value.map { $0.account.id } } }
|
||||
.catch { error -> AnyPublisher<Result<[Mastodon.Entity.Account.ID], Error>, Never> in
|
||||
if let apiError = error as? Mastodon.API.Error, apiError.httpResponseStatus == .notFound {
|
||||
return context.apiService.suggestionAccount(domain: box.domain, query: nil, mastodonAuthenticationBox: box)
|
||||
.map { response in Result<[Mastodon.Entity.Account.ID], Error> { response.value.map { $0.id } } }
|
||||
.catch { error in Just(Result<[Mastodon.Entity.Account.ID], Error> { throw error }) }
|
||||
.eraseToAnyPublisher()
|
||||
} else {
|
||||
return Just(Result<[Mastodon.Entity.Account.ID], Error> { throw error })
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
switch result {
|
||||
case .success(let userIDs):
|
||||
self.receiveAccounts(ids: userIDs)
|
||||
case .failure(let error):
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
func receiveAccounts(ids: [Mastodon.Entity.Account.ID]) {
|
||||
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||
return
|
||||
}
|
||||
let userFetchRequest = MastodonUser.sortedFetchRequest
|
||||
userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids)
|
||||
let mastodonUsers: [MastodonUser]? = {
|
||||
let userFetchRequest = MastodonUser.sortedFetchRequest
|
||||
userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids)
|
||||
userFetchRequest.returnsObjectsAsFaults = false
|
||||
do {
|
||||
return try self.context.managedObjectContext.fetch(userFetchRequest)
|
||||
} catch {
|
||||
assertionFailure(error.localizedDescription)
|
||||
return nil
|
||||
}
|
||||
}()
|
||||
guard let users = mastodonUsers else { return }
|
||||
let objectIDs: [NSManagedObjectID] = users
|
||||
.compactMap { object in
|
||||
ids.firstIndex(of: object.id).map { index in (index, object) }
|
||||
}
|
||||
.sorted { $0.0 < $1.0 }
|
||||
.map { $0.1.objectID }
|
||||
|
||||
// append at front
|
||||
let newObjectIDs = objectIDs.filter { !self.recommendAccounts.contains($0) }
|
||||
self.recommendAccounts = newObjectIDs + self.recommendAccounts
|
||||
|
||||
guard let dataSource = self.accountDiffableDataSource else { return }
|
||||
var snapshot = NSDiffableDataSourceSnapshot<RecommendAccountSection, NSManagedObjectID>()
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(self.recommendAccounts, toSection: .main)
|
||||
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,350 @@
|
|||
//
|
||||
// SearchDetailViewController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-7-13.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
import Pageboy
|
||||
|
||||
final class SearchDetailViewController: PageboyViewController, NeedsDependency {
|
||||
|
||||
let logger = Logger(subsystem: "SearchDetail", category: "UI")
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
var observations = Set<NSKeyValueObservation>()
|
||||
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
|
||||
var viewModel: SearchDetailViewModel!
|
||||
var viewControllers: [SearchResultViewController]!
|
||||
|
||||
let navigationBarVisualEffectBackgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
|
||||
let navigationBarBackgroundView = UIView()
|
||||
let navigationBar: UINavigationBar = {
|
||||
let navigationItem = UINavigationItem()
|
||||
let barAppearance = UINavigationBarAppearance()
|
||||
barAppearance.configureWithTransparentBackground()
|
||||
navigationItem.standardAppearance = barAppearance
|
||||
navigationItem.compactAppearance = barAppearance
|
||||
navigationItem.scrollEdgeAppearance = barAppearance
|
||||
|
||||
let navigationBar = UINavigationBar(
|
||||
frame: CGRect(x: 0, y: 0, width: 300, height: 100)
|
||||
)
|
||||
navigationBar.setItems([navigationItem], animated: false)
|
||||
return navigationBar
|
||||
}()
|
||||
let searchBar: UISearchBar = {
|
||||
let searchBar = UISearchBar()
|
||||
searchBar.placeholder = L10n.Scene.Search.SearchBar.placeholder
|
||||
searchBar.scopeButtonTitles = SearchDetailViewModel.SearchScope.allCases.map { $0.segmentedControlTitle }
|
||||
searchBar.sizeToFit()
|
||||
searchBar.scopeBarBackgroundImage = UIImage()
|
||||
return searchBar
|
||||
}()
|
||||
|
||||
private(set) lazy var searchHistoryViewController: SearchHistoryViewController = {
|
||||
let searchHistoryViewController = SearchHistoryViewController()
|
||||
searchHistoryViewController.context = context
|
||||
searchHistoryViewController.coordinator = coordinator
|
||||
searchHistoryViewController.viewModel = SearchHistoryViewModel(context: context)
|
||||
return searchHistoryViewController
|
||||
}()
|
||||
}
|
||||
|
||||
extension SearchDetailViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
setupBackgroundColor(theme: ThemeService.shared.currentTheme.value)
|
||||
ThemeService.shared.currentTheme
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] theme in
|
||||
guard let self = self else { return }
|
||||
self.setupBackgroundColor(theme: theme)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
navigationBar.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(navigationBar)
|
||||
NSLayoutConstraint.activate([
|
||||
navigationBar.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor),
|
||||
navigationBar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
navigationBar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
])
|
||||
setupSearchBar()
|
||||
navigationBar.layer.observe(\.bounds, options: [.new]) { [weak self] navigationBar, _ in
|
||||
guard let self = self else { return }
|
||||
self.viewModel.navigationBarFrame.value = navigationBar.frame
|
||||
}
|
||||
.store(in: &observations)
|
||||
|
||||
navigationBarBackgroundView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.insertSubview(navigationBarBackgroundView, belowSubview: navigationBar)
|
||||
NSLayoutConstraint.activate([
|
||||
navigationBarBackgroundView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
navigationBarBackgroundView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
navigationBarBackgroundView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
navigationBarBackgroundView.bottomAnchor.constraint(equalTo: navigationBar.bottomAnchor),
|
||||
])
|
||||
|
||||
navigationBarVisualEffectBackgroundView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.insertSubview(navigationBarVisualEffectBackgroundView, belowSubview: navigationBarBackgroundView)
|
||||
NSLayoutConstraint.activate([
|
||||
navigationBarVisualEffectBackgroundView.topAnchor.constraint(equalTo: navigationBarBackgroundView.topAnchor),
|
||||
navigationBarVisualEffectBackgroundView.leadingAnchor.constraint(equalTo: navigationBarBackgroundView.leadingAnchor),
|
||||
navigationBarVisualEffectBackgroundView.trailingAnchor.constraint(equalTo: navigationBarBackgroundView.trailingAnchor),
|
||||
navigationBarVisualEffectBackgroundView.bottomAnchor.constraint(equalTo: navigationBarBackgroundView.bottomAnchor),
|
||||
])
|
||||
|
||||
addChild(searchHistoryViewController)
|
||||
searchHistoryViewController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(searchHistoryViewController.view)
|
||||
searchHistoryViewController.didMove(toParent: self)
|
||||
NSLayoutConstraint.activate([
|
||||
searchHistoryViewController.view.topAnchor.constraint(equalTo: navigationBarBackgroundView.bottomAnchor),
|
||||
searchHistoryViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
searchHistoryViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
searchHistoryViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
|
||||
transition = Transition(style: .fade, duration: 0.1)
|
||||
isScrollEnabled = false
|
||||
|
||||
viewControllers = viewModel.searchScopes.map { scope in
|
||||
let searchResultViewController = SearchResultViewController()
|
||||
searchResultViewController.context = context
|
||||
searchResultViewController.coordinator = coordinator
|
||||
searchResultViewController.viewModel = SearchResultViewModel(context: context, searchScope: scope)
|
||||
|
||||
// bind searchText
|
||||
viewModel.searchText
|
||||
.assign(to: \.value, on: searchResultViewController.viewModel.searchText)
|
||||
.store(in: &searchResultViewController.disposeBag)
|
||||
|
||||
// bind navigationBarFrame
|
||||
viewModel.navigationBarFrame
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: \.value, on: searchResultViewController.viewModel.navigationBarFrame)
|
||||
.store(in: &searchResultViewController.disposeBag)
|
||||
return searchResultViewController
|
||||
}
|
||||
|
||||
// set initial items from "all" search scope for non-appeared lists
|
||||
if let allSearchScopeViewController = viewControllers.first(where: { $0.viewModel.searchScope == .all }) {
|
||||
allSearchScopeViewController.viewModel.items
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] items in
|
||||
guard let self = self else { return }
|
||||
guard self.currentViewController === allSearchScopeViewController else { return }
|
||||
for viewController in self.viewControllers where viewController != allSearchScopeViewController {
|
||||
// do not change appeared list
|
||||
guard !viewController.viewModel.viewDidAppear.value else { continue }
|
||||
// set initial items
|
||||
switch viewController.viewModel.searchScope {
|
||||
case .all:
|
||||
assertionFailure()
|
||||
break
|
||||
case .people:
|
||||
viewController.viewModel.items.value = items.filter { item in
|
||||
guard case .account = item else { return false }
|
||||
return true
|
||||
}
|
||||
case .hashtags:
|
||||
viewController.viewModel.items.value = items.filter { item in
|
||||
guard case .hashtag = item else { return false }
|
||||
return true
|
||||
}
|
||||
case .posts:
|
||||
viewController.viewModel.items.value = items.filter { item in
|
||||
guard case .status = item else { return false }
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &allSearchScopeViewController.disposeBag)
|
||||
}
|
||||
|
||||
dataSource = self
|
||||
delegate = self
|
||||
|
||||
// bind search bar scope
|
||||
viewModel.selectedSearchScope
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] searchScope in
|
||||
guard let self = self else { return }
|
||||
if let index = self.viewModel.searchScopes.firstIndex(of: searchScope) {
|
||||
self.searchBar.selectedScopeButtonIndex = index
|
||||
self.scrollToPage(.at(index: index), animated: true)
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
// bind search trigger
|
||||
viewModel.searchText
|
||||
.removeDuplicates()
|
||||
.throttle(for: 0.5, scheduler: DispatchQueue.main, latest: true)
|
||||
.sink { [weak self] searchText in
|
||||
guard let self = self else { return }
|
||||
guard let searchResultViewController = self.currentViewController as? SearchResultViewController else {
|
||||
return
|
||||
}
|
||||
self.logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): trigger search \(searchText)")
|
||||
searchResultViewController.viewModel.stateMachine.enter(SearchResultViewModel.State.Loading.self)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
// bind search history display
|
||||
viewModel.searchText
|
||||
.removeDuplicates()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] searchText in
|
||||
guard let self = self else { return }
|
||||
self.searchHistoryViewController.view.isHidden = !searchText.isEmpty
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
navigationController?.setNavigationBarHidden(true, animated: animated)
|
||||
searchBar.setShowsScope(true, animated: false)
|
||||
searchBar.setNeedsLayout()
|
||||
searchBar.layoutIfNeeded()
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
|
||||
if !isModal {
|
||||
// prevent bar restore conflict with modal style issue
|
||||
navigationController?.setNavigationBarHidden(false, animated: animated)
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
searchBar.setShowsCancelButton(true, animated: animated)
|
||||
searchBar.becomeFirstResponder()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension SearchDetailViewController {
|
||||
private func setupSearchBar() {
|
||||
navigationBar.topItem?.titleView = searchBar
|
||||
|
||||
searchBar.delegate = self
|
||||
}
|
||||
|
||||
private func setupBackgroundColor(theme: Theme) {
|
||||
navigationBarBackgroundView.backgroundColor = theme.navigationBarBackgroundColor
|
||||
navigationBar.tintColor = Asset.Colors.brandBlue.color
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UISearchBarDelegate
|
||||
extension SearchDetailViewController: UISearchBarDelegate {
|
||||
|
||||
func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) {
|
||||
viewModel.selectedSearchScope.value = viewModel.searchScopes[selectedScope]
|
||||
}
|
||||
|
||||
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
|
||||
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): searchTest \(searchText)")
|
||||
viewModel.searchText.value = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
|
||||
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
|
||||
|
||||
// dismiss or pop
|
||||
if isModal {
|
||||
dismiss(animated: true, completion: nil)
|
||||
} else {
|
||||
navigationController?.popViewController(animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - PageboyViewControllerDataSource
|
||||
extension SearchDetailViewController: PageboyViewControllerDataSource {
|
||||
|
||||
func numberOfViewControllers(in pageboyViewController: PageboyViewController) -> Int {
|
||||
return viewControllers.count
|
||||
}
|
||||
|
||||
func viewController(for pageboyViewController: PageboyViewController, at index: PageboyViewController.PageIndex) -> UIViewController? {
|
||||
guard index < viewControllers.count else { return nil }
|
||||
return viewControllers[index]
|
||||
}
|
||||
|
||||
func defaultPage(for pageboyViewController: PageboyViewController) -> PageboyViewController.Page? {
|
||||
return .first
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - PageboyViewControllerDelegate
|
||||
extension SearchDetailViewController: PageboyViewControllerDelegate {
|
||||
|
||||
func pageboyViewController(
|
||||
_ pageboyViewController: PageboyViewController,
|
||||
willScrollToPageAt index: PageboyViewController.PageIndex,
|
||||
direction: PageboyViewController.NavigationDirection,
|
||||
animated: Bool
|
||||
) {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
func pageboyViewController(
|
||||
_ pageboyViewController: PageboyViewController,
|
||||
didScrollTo position: CGPoint,
|
||||
direction: PageboyViewController.NavigationDirection,
|
||||
animated: Bool
|
||||
) {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
func pageboyViewController(
|
||||
_ pageboyViewController: PageboyViewController,
|
||||
didCancelScrollToPageAt index: PageboyViewController.PageIndex,
|
||||
returnToPageAt previousIndex: PageboyViewController.PageIndex
|
||||
) {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
func pageboyViewController(
|
||||
_ pageboyViewController: PageboyViewController,
|
||||
didScrollToPageAt index: PageboyViewController.PageIndex,
|
||||
direction: PageboyViewController.NavigationDirection,
|
||||
animated: Bool
|
||||
) {
|
||||
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): index \(index)")
|
||||
|
||||
let searchResultViewController = viewControllers[index]
|
||||
viewModel.selectedSearchScope.value = searchResultViewController.viewModel.searchScope
|
||||
|
||||
// trigger fetch
|
||||
searchResultViewController.viewModel.stateMachine.enter(SearchResultViewModel.State.Loading.self)
|
||||
}
|
||||
|
||||
|
||||
func pageboyViewController(
|
||||
_ pageboyViewController: PageboyViewController,
|
||||
didReloadWith currentViewController: UIViewController,
|
||||
currentPageIndex: PageboyViewController.PageIndex
|
||||
) {
|
||||
// do nothing
|
||||
}
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
//
|
||||
// SearchDetailViewModel.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-7-13.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import Foundation
|
||||
import CoreGraphics
|
||||
import Combine
|
||||
import MastodonSDK
|
||||
|
||||
final class SearchDetailViewModel {
|
||||
|
||||
// input
|
||||
var needsBecomeFirstResponder = false
|
||||
let viewDidAppear = PassthroughSubject<Void, Never>()
|
||||
let navigationBarFrame = CurrentValueSubject<CGRect, Never>(.zero)
|
||||
|
||||
// output
|
||||
let searchScopes = SearchScope.allCases
|
||||
let selectedSearchScope = CurrentValueSubject<SearchScope, Never>(.all)
|
||||
let searchText: CurrentValueSubject<String, Never>
|
||||
let searchActionPublisher = PassthroughSubject<Void, Never>()
|
||||
|
||||
init(initialSearchText: String = "") {
|
||||
self.searchText = CurrentValueSubject(initialSearchText)
|
||||
}
|
||||
|
||||
deinit {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension SearchDetailViewModel {
|
||||
enum SearchScope: CaseIterable {
|
||||
case all
|
||||
case people
|
||||
case hashtags
|
||||
case posts
|
||||
|
||||
var segmentedControlTitle: String {
|
||||
switch self {
|
||||
case .all: return L10n.Scene.Search.Searching.Segment.all
|
||||
case .people: return L10n.Scene.Search.Searching.Segment.people
|
||||
case .hashtags: return L10n.Scene.Search.Searching.Segment.hashtags
|
||||
case .posts: return L10n.Scene.Search.Searching.Segment.posts
|
||||
}
|
||||
}
|
||||
|
||||
var searchType: Mastodon.API.V2.Search.SearchType {
|
||||
switch self {
|
||||
case .all: return .default
|
||||
case .people: return .accounts
|
||||
case .hashtags: return .hashtags
|
||||
case .posts: return .statuses
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,128 @@
|
|||
//
|
||||
// SearchHistoryViewController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-7-13.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
import CoreDataStack
|
||||
|
||||
final class SearchHistoryViewController: UIViewController, NeedsDependency {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
|
||||
var viewModel: SearchHistoryViewModel!
|
||||
|
||||
let searchHistoryTableHeaderView = SearchHistoryTableHeaderView()
|
||||
let tableView: UITableView = {
|
||||
let tableView = UITableView()
|
||||
tableView.register(SearchResultTableViewCell.self, forCellReuseIdentifier: String(describing: SearchResultTableViewCell.self))
|
||||
// tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self))
|
||||
tableView.separatorStyle = .none
|
||||
tableView.tableFooterView = UIView()
|
||||
tableView.backgroundColor = .clear
|
||||
return tableView
|
||||
}()
|
||||
|
||||
}
|
||||
|
||||
extension SearchHistoryViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
setupBackgroundColor(theme: ThemeService.shared.currentTheme.value)
|
||||
ThemeService.shared.currentTheme
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] theme in
|
||||
guard let self = self else { return }
|
||||
self.setupBackgroundColor(theme: theme)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
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(
|
||||
tableView: tableView,
|
||||
dependency: self
|
||||
)
|
||||
|
||||
searchHistoryTableHeaderView.delegate = self
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
tableView.deselectRow(with: transitionCoordinator, animated: animated)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension SearchHistoryViewController {
|
||||
private func setupBackgroundColor(theme: Theme) {
|
||||
view.backgroundColor = theme.systemGroupedBackgroundColor
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDelegate
|
||||
extension SearchHistoryViewController: UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
||||
switch section {
|
||||
case 0:
|
||||
return searchHistoryTableHeaderView
|
||||
default:
|
||||
return UIView()
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
|
||||
switch section {
|
||||
case 0:
|
||||
return UITableView.automaticDimension
|
||||
default:
|
||||
return .leastNonzeroMagnitude
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||
|
||||
viewModel.persistSearchHistory(for: item)
|
||||
|
||||
switch item {
|
||||
case .account(let objectID):
|
||||
guard let user = try? viewModel.searchHistoryFetchedResultController.fetchedResultsController.managedObjectContext.existingObject(with: objectID) as? MastodonUser else { return }
|
||||
let profileViewModel = CachedProfileViewModel(context: context, mastodonUser: user)
|
||||
coordinator.present(scene: .profile(viewModel: profileViewModel), from: self, transition: .show)
|
||||
case .hashtag(let objectID):
|
||||
guard let hashtag = try? viewModel.searchHistoryFetchedResultController.fetchedResultsController.managedObjectContext.existingObject(with: objectID) as? Tag else { return }
|
||||
let hashtagViewModel = HashtagTimelineViewModel(context: context, hashtag: hashtag.name)
|
||||
coordinator.present(scene: .hashtagTimeline(viewModel: hashtagViewModel), from: self, transition: .show)
|
||||
case .status(let objectID, _):
|
||||
guard let status = try? viewModel.searchHistoryFetchedResultController.fetchedResultsController.managedObjectContext.existingObject(with: objectID) as? Status else { return }
|
||||
let threadViewModel = CachedThreadViewModel(context: context, status: status)
|
||||
coordinator.present(scene: .thread(viewModel: threadViewModel), from: self, transition: .show)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SearchHistoryTableHeaderViewDelegate
|
||||
extension SearchHistoryViewController: SearchHistoryTableHeaderViewDelegate {
|
||||
func searchHistoryTableHeaderView(_ searchHistoryTableHeaderView: SearchHistoryTableHeaderView, clearSearchHistoryButtonDidPressed button: UIButton) {
|
||||
viewModel.clearSearchHistory()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,136 @@
|
|||
//
|
||||
// SearchHistoryViewModel.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-7-15.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
import CoreDataStack
|
||||
import CommonOSLog
|
||||
|
||||
final class SearchHistoryViewModel {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
// input
|
||||
let context: AppContext
|
||||
let searchHistoryFetchedResultController: SearchHistoryFetchedResultController
|
||||
|
||||
// output
|
||||
var diffableDataSource: UITableViewDiffableDataSource<SearchHistorySection, SearchHistoryItem>!
|
||||
|
||||
init(context: AppContext) {
|
||||
self.context = context
|
||||
self.searchHistoryFetchedResultController = SearchHistoryFetchedResultController(managedObjectContext: context.managedObjectContext)
|
||||
|
||||
// may block main queue by large dataset
|
||||
searchHistoryFetchedResultController.objectIDs
|
||||
.removeDuplicates()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] objectIDs in
|
||||
guard let self = self else { return }
|
||||
guard let diffableDataSource = self.diffableDataSource else { return }
|
||||
let managedObjectContext = self.searchHistoryFetchedResultController.fetchedResultsController.managedObjectContext
|
||||
|
||||
var items: [SearchHistoryItem] = []
|
||||
for objectID in objectIDs {
|
||||
guard let searchHistory = try? managedObjectContext.existingObject(with: objectID) as? SearchHistory else { continue }
|
||||
if let account = searchHistory.account {
|
||||
let item: SearchHistoryItem = .account(objectID: account.objectID)
|
||||
guard !items.contains(item) else { continue }
|
||||
items.append(item)
|
||||
} else if let hashtag = searchHistory.hashtag {
|
||||
let item: SearchHistoryItem = .hashtag(objectID: hashtag.objectID)
|
||||
guard !items.contains(item) else { continue }
|
||||
items.append(item)
|
||||
} else {
|
||||
// TODO: status
|
||||
}
|
||||
}
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<SearchHistorySection, SearchHistoryItem>()
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(items, toSection: .main)
|
||||
|
||||
diffableDataSource.apply(snapshot, animatingDifferences: false)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
try? searchHistoryFetchedResultController.fetchedResultsController.performFetch()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension SearchHistoryViewModel {
|
||||
func setupDiffableDataSource(
|
||||
tableView: UITableView,
|
||||
dependency: NeedsDependency
|
||||
) {
|
||||
diffableDataSource = SearchHistorySection.tableViewDiffableDataSource(
|
||||
for: tableView,
|
||||
dependency: dependency
|
||||
)
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<SearchHistorySection, SearchHistoryItem>()
|
||||
snapshot.appendSections([.main])
|
||||
diffableDataSource.apply(snapshot, animatingDifferences: false)
|
||||
}
|
||||
}
|
||||
|
||||
extension SearchHistoryViewModel {
|
||||
func persistSearchHistory(for item: SearchHistoryItem) {
|
||||
switch item {
|
||||
case .account(let objectID):
|
||||
let managedObjectContext = context.backgroundManagedObjectContext
|
||||
managedObjectContext.performChanges {
|
||||
guard let user = try? managedObjectContext.existingObject(with: objectID) as? MastodonUser else { return }
|
||||
if let searchHistory = user.searchHistory {
|
||||
searchHistory.update(updatedAt: Date())
|
||||
} else {
|
||||
SearchHistory.insert(into: managedObjectContext, account: user)
|
||||
}
|
||||
}
|
||||
.sink { result in
|
||||
// do nothing
|
||||
}
|
||||
.store(in: &context.disposeBag)
|
||||
|
||||
case .hashtag(let objectID):
|
||||
let managedObjectContext = context.backgroundManagedObjectContext
|
||||
managedObjectContext.performChanges {
|
||||
guard let hashtag = try? managedObjectContext.existingObject(with: objectID) as? Tag else { return }
|
||||
if let searchHistory = hashtag.searchHistory {
|
||||
searchHistory.update(updatedAt: Date())
|
||||
} else {
|
||||
SearchHistory.insert(into: managedObjectContext, hashtag: hashtag)
|
||||
}
|
||||
}
|
||||
.sink { result in
|
||||
// do nothing
|
||||
}
|
||||
.store(in: &context.disposeBag)
|
||||
|
||||
case .status:
|
||||
// FIXME:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func clearSearchHistory() {
|
||||
let managedObjectContext = context.backgroundManagedObjectContext
|
||||
managedObjectContext.performChanges {
|
||||
let request = SearchHistory.sortedFetchRequest
|
||||
let searchHistories = managedObjectContext.safeFetch(request)
|
||||
for searchHistory in searchHistories {
|
||||
managedObjectContext.delete(searchHistory)
|
||||
}
|
||||
}
|
||||
.sink { result in
|
||||
// do nothing
|
||||
}
|
||||
.store(in: &context.disposeBag)
|
||||
|
||||
}
|
||||
}
|
|
@ -0,0 +1,96 @@
|
|||
//
|
||||
// SearchHistoryTableHeaderView.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-7-14.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
protocol SearchHistoryTableHeaderViewDelegate: AnyObject {
|
||||
func searchHistoryTableHeaderView(_ searchHistoryTableHeaderView: SearchHistoryTableHeaderView, clearSearchHistoryButtonDidPressed button: UIButton)
|
||||
}
|
||||
|
||||
final class SearchHistoryTableHeaderView: UIView {
|
||||
|
||||
let logger = Logger(subsystem: "SearchHistory", category: "UI")
|
||||
|
||||
weak var delegate: SearchHistoryTableHeaderViewDelegate?
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
let recentSearchesLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold))
|
||||
label.textColor = Asset.Colors.Label.primary.color
|
||||
label.text = L10n.Scene.Search.Searching.recentSearch
|
||||
return label
|
||||
}()
|
||||
|
||||
let clearSearchHistoryButton: HighlightDimmableButton = {
|
||||
let button = HighlightDimmableButton(type: .custom)
|
||||
button.expandEdgeInsets = UIEdgeInsets(top: -10, left: -10, bottom: -10, right: -10)
|
||||
button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal)
|
||||
button.setTitle(L10n.Scene.Search.Searching.clear, for: .normal)
|
||||
return button
|
||||
}()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
_init()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
_init()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension SearchHistoryTableHeaderView {
|
||||
private func _init() {
|
||||
preservesSuperviewLayoutMargins = true
|
||||
|
||||
recentSearchesLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(recentSearchesLabel)
|
||||
NSLayoutConstraint.activate([
|
||||
recentSearchesLabel.topAnchor.constraint(equalTo: topAnchor, constant: 16),
|
||||
recentSearchesLabel.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor),
|
||||
bottomAnchor.constraint(equalTo: recentSearchesLabel.bottomAnchor, constant: 16),
|
||||
])
|
||||
|
||||
clearSearchHistoryButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(clearSearchHistoryButton)
|
||||
NSLayoutConstraint.activate([
|
||||
clearSearchHistoryButton.centerYAnchor.constraint(equalTo: recentSearchesLabel.centerYAnchor),
|
||||
clearSearchHistoryButton.leadingAnchor.constraint(equalTo: recentSearchesLabel.trailingAnchor),
|
||||
clearSearchHistoryButton.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor),
|
||||
])
|
||||
clearSearchHistoryButton.setContentHuggingPriority(.defaultHigh + 10, for: .horizontal)
|
||||
|
||||
clearSearchHistoryButton.addTarget(self, action: #selector(SearchHistoryTableHeaderView.clearSearchHistoryButtonDidPressed(_:)), for: .touchUpInside)
|
||||
|
||||
setupBackgroundColor(theme: ThemeService.shared.currentTheme.value)
|
||||
ThemeService.shared.currentTheme
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] theme in
|
||||
guard let self = self else { return }
|
||||
self.setupBackgroundColor(theme: theme)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
}
|
||||
|
||||
extension SearchHistoryTableHeaderView {
|
||||
@objc private func clearSearchHistoryButtonDidPressed(_ sender: UIButton) {
|
||||
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
|
||||
delegate?.searchHistoryTableHeaderView(self, clearSearchHistoryButtonDidPressed: sender)
|
||||
}
|
||||
}
|
||||
|
||||
extension SearchHistoryTableHeaderView {
|
||||
private func setupBackgroundColor(theme: Theme) {
|
||||
backgroundColor = theme.systemGroupedBackgroundColor
|
||||
}
|
||||
}
|
|
@ -0,0 +1,75 @@
|
|||
//
|
||||
// SearchResultViewController+StatusProvider.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-7-14.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
|
||||
// MARK: - StatusProvider
|
||||
extension SearchResultViewController: 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 ?? cell.flatMap({ self.tableView.indexPath(for: $0) }),
|
||||
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 self.viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext
|
||||
}
|
||||
|
||||
var tableViewDiffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? {
|
||||
return nil
|
||||
}
|
||||
|
||||
func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? {
|
||||
return nil
|
||||
}
|
||||
|
||||
func items(indexPaths: [IndexPath]) -> [Item] {
|
||||
return []
|
||||
}
|
||||
|
||||
func statusObjectItems(indexPaths: [IndexPath]) -> [StatusObjectItem] {
|
||||
guard let diffableDataSource = self.viewModel.diffableDataSource else { return [] }
|
||||
let items = indexPaths.compactMap { diffableDataSource.itemIdentifier(for: $0)?.statusObjectItem }
|
||||
return items
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension SearchResultViewController: UserProvider {}
|
|
@ -0,0 +1,254 @@
|
|||
//
|
||||
// SearchResultViewController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-7-14.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
import AVKit
|
||||
import GameplayKit
|
||||
|
||||
final class SearchResultViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
|
||||
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
|
||||
let mediaPreviewTransitionController = MediaPreviewTransitionController()
|
||||
|
||||
var viewModel: SearchResultViewModel!
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
let tableView: UITableView = {
|
||||
let tableView = UITableView()
|
||||
tableView.register(SearchResultTableViewCell.self, forCellReuseIdentifier: String(describing: SearchResultTableViewCell.self))
|
||||
tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self))
|
||||
tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
|
||||
tableView.separatorStyle = .none
|
||||
tableView.tableFooterView = UIView()
|
||||
tableView.backgroundColor = .clear
|
||||
return tableView
|
||||
}()
|
||||
|
||||
}
|
||||
|
||||
extension SearchResultViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
setupBackgroundColor(theme: ThemeService.shared.currentTheme.value)
|
||||
ThemeService.shared.currentTheme
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] theme in
|
||||
guard let self = self else { return }
|
||||
self.setupBackgroundColor(theme: theme)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
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
|
||||
tableView.prefetchDataSource = self
|
||||
viewModel.setupDiffableDataSource(
|
||||
tableView: tableView,
|
||||
dependency: self,
|
||||
statusTableViewCellDelegate: self
|
||||
)
|
||||
|
||||
// listen keyboard events and set content inset
|
||||
let keyboardEventPublishers = Publishers.CombineLatest3(
|
||||
KeyboardResponderService.shared.isShow,
|
||||
KeyboardResponderService.shared.state,
|
||||
KeyboardResponderService.shared.endFrame
|
||||
)
|
||||
Publishers.CombineLatest3(
|
||||
keyboardEventPublishers,
|
||||
viewModel.viewDidAppear,
|
||||
viewModel.didDataSourceUpdate
|
||||
)
|
||||
.sink(receiveValue: { [weak self] keyboardEvents, _, _ in
|
||||
guard let self = self else { return }
|
||||
let (isShow, state, endFrame) = keyboardEvents
|
||||
|
||||
// update keyboard background color
|
||||
guard isShow, state == .dock else {
|
||||
self.tableView.contentInset.bottom = 0
|
||||
self.tableView.verticalScrollIndicatorInsets.bottom = 0
|
||||
return
|
||||
}
|
||||
// isShow AND dock state
|
||||
|
||||
// adjust inset for tableView
|
||||
let contentFrame = self.view.convert(self.tableView.frame, to: nil)
|
||||
let padding = contentFrame.maxY - endFrame.minY
|
||||
guard padding > 0 else {
|
||||
self.tableView.contentInset.bottom = self.view.safeAreaInsets.bottom
|
||||
self.tableView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom
|
||||
return
|
||||
}
|
||||
|
||||
self.tableView.contentInset.bottom = padding - self.view.safeAreaInsets.bottom
|
||||
self.tableView.verticalScrollIndicatorInsets.bottom = padding - self.view.safeAreaInsets.bottom
|
||||
})
|
||||
.store(in: &disposeBag)
|
||||
|
||||
// works for already onscreen page
|
||||
viewModel.navigationBarFrame
|
||||
.removeDuplicates()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] frame in
|
||||
guard let self = self else { return }
|
||||
guard self.viewModel.viewDidAppear.value else { return }
|
||||
self.tableView.contentInset.top = frame.height
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
// works for appearing page
|
||||
if !viewModel.viewDidAppear.value {
|
||||
tableView.contentInset.top = viewModel.navigationBarFrame.value.height
|
||||
tableView.contentOffset.y = -viewModel.navigationBarFrame.value.height
|
||||
}
|
||||
|
||||
aspectViewWillAppear(animated)
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
viewModel.viewDidAppear.value = true
|
||||
}
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
|
||||
aspectViewDidDisappear(animated)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension SearchResultViewController {
|
||||
private func setupBackgroundColor(theme: Theme) {
|
||||
view.backgroundColor = theme.systemGroupedBackgroundColor
|
||||
// tableView.backgroundColor = theme.systemBackgroundColor
|
||||
// searchHeader.backgroundColor = theme.systemGroupedBackgroundColor
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - StatusTableViewCellDelegate
|
||||
extension SearchResultViewController: StatusTableViewCellDelegate {
|
||||
weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self }
|
||||
func parent() -> UIViewController { return self }
|
||||
}
|
||||
|
||||
// MARK: - StatusTableViewControllerAspect
|
||||
extension SearchResultViewController: StatusTableViewControllerAspect { }
|
||||
|
||||
// MARK: - LoadMoreConfigurableTableViewContainer
|
||||
extension SearchResultViewController: LoadMoreConfigurableTableViewContainer {
|
||||
typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell
|
||||
typealias LoadingState = SearchResultViewModel.State.Loading
|
||||
var loadMoreConfigurableTableView: UITableView { tableView }
|
||||
var loadMoreConfigurableStateMachine: GKStateMachine { viewModel.stateMachine }
|
||||
}
|
||||
|
||||
// MARK: - UIScrollViewDelegate
|
||||
extension SearchResultViewController {
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
aspectScrollViewDidScroll(scrollView)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TableViewCellHeightCacheableContainer
|
||||
extension SearchResultViewController: TableViewCellHeightCacheableContainer {
|
||||
var cellFrameCache: NSCache<NSNumber, NSValue> {
|
||||
viewModel.cellFrameCache
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDelegate
|
||||
extension SearchResultViewController: UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
aspectTableView(tableView, estimatedHeightForRowAt: indexPath)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||
|
||||
viewModel.persistSearchHistory(for: item)
|
||||
|
||||
switch item {
|
||||
case .account(let account):
|
||||
let profileViewModel = RemoteProfileViewModel(context: context, userID: account.id)
|
||||
coordinator.present(scene: .profile(viewModel: profileViewModel), from: self, transition: .show)
|
||||
case .hashtag(let hashtag):
|
||||
let hashtagViewModel = HashtagTimelineViewModel(context: context, hashtag: hashtag.name)
|
||||
coordinator.present(scene: .hashtagTimeline(viewModel: hashtagViewModel), from: self, transition: .show)
|
||||
case .status:
|
||||
aspectTableView(tableView, didSelectRowAt: indexPath)
|
||||
case .bottomLoader:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||
aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
|
||||
aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDataSourcePrefetching
|
||||
extension SearchResultViewController: UITableViewDataSourcePrefetching {
|
||||
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
|
||||
aspectTableView(tableView, cancelPrefetchingForRowsAt: indexPaths)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
|
||||
aspectTableView(tableView, cancelPrefetchingForRowsAt: indexPaths)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AVPlayerViewControllerDelegate
|
||||
extension SearchResultViewController: AVPlayerViewControllerDelegate {
|
||||
func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
|
||||
handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator)
|
||||
}
|
||||
|
||||
func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
|
||||
handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,201 @@
|
|||
//
|
||||
// SearchResultViewModel+State.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-7-14.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import Foundation
|
||||
import GameplayKit
|
||||
import MastodonSDK
|
||||
|
||||
extension SearchResultViewModel {
|
||||
class State: GKState {
|
||||
weak var viewModel: SearchResultViewModel?
|
||||
|
||||
init(viewModel: SearchResultViewModel) {
|
||||
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, debugDescription, previousState.debugDescription)
|
||||
// viewModel?.loadOldestStateMachinePublisher.send(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SearchResultViewModel.State {
|
||||
class Initial: SearchResultViewModel.State {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
guard let viewModel = viewModel else { return false }
|
||||
return stateClass == Loading.self && !viewModel.searchText.value.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
class Loading: SearchResultViewModel.State {
|
||||
let logger = Logger(subsystem: "SearchResultViewModel.State.Loading", category: "Logic")
|
||||
|
||||
var previousSearchText = ""
|
||||
var offset: Int? = nil
|
||||
var latestLoadingToken = UUID()
|
||||
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
guard let viewModel = self.viewModel else { return false }
|
||||
switch stateClass {
|
||||
case is Fail.Type, is Idle.Type, is NoMore.Type:
|
||||
return true
|
||||
case is Loading.Type:
|
||||
return viewModel.searchText.value != previousSearchText
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
override func didEnter(from previousState: GKState?) {
|
||||
super.didEnter(from: previousState)
|
||||
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
||||
guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||
assertionFailure()
|
||||
stateMachine.enter(Fail.self)
|
||||
return
|
||||
}
|
||||
|
||||
let domain = activeMastodonAuthenticationBox.domain
|
||||
|
||||
let searchText = viewModel.searchText.value
|
||||
let searchType = viewModel.searchScope.searchType
|
||||
|
||||
if previousState is NoMore && previousSearchText == searchText {
|
||||
// same searchText from NoMore. should silent refresh
|
||||
} else {
|
||||
// trigger bottom loader display
|
||||
viewModel.items.value = viewModel.items.value
|
||||
}
|
||||
|
||||
guard !searchText.isEmpty else {
|
||||
stateMachine.enter(Fail.self)
|
||||
return
|
||||
}
|
||||
|
||||
if searchText != previousSearchText {
|
||||
previousSearchText = searchText
|
||||
offset = nil
|
||||
} else {
|
||||
offset = viewModel.items.value.count
|
||||
}
|
||||
|
||||
// not set offset for all case
|
||||
// and assert other cases the items are all the same type elements
|
||||
let _offset: Int? = {
|
||||
switch searchType {
|
||||
case .default: return nil
|
||||
default: return offset
|
||||
}
|
||||
}()
|
||||
|
||||
let query = Mastodon.API.V2.Search.Query(
|
||||
q: searchText,
|
||||
type: searchType,
|
||||
accountID: nil,
|
||||
maxID: nil,
|
||||
minID: nil,
|
||||
excludeUnreviewed: nil,
|
||||
resolve: nil,
|
||||
limit: nil,
|
||||
offset: _offset,
|
||||
following: nil
|
||||
)
|
||||
|
||||
let id = UUID()
|
||||
latestLoadingToken = id
|
||||
|
||||
viewModel.context.apiService.search(
|
||||
domain: domain,
|
||||
query: query,
|
||||
mastodonAuthenticationBox: activeMastodonAuthenticationBox
|
||||
)
|
||||
.sink { [weak self] completion in
|
||||
guard let self = self else { return }
|
||||
switch completion {
|
||||
case .failure(let error):
|
||||
self.logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): search \(searchText) fail: \(error.localizedDescription)")
|
||||
stateMachine.enter(Fail.self)
|
||||
case .finished:
|
||||
self.logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): search \(searchText) success")
|
||||
}
|
||||
} receiveValue: { [weak self] response in
|
||||
guard let self = self else { return }
|
||||
|
||||
// discard result when search text is outdated
|
||||
guard searchText == self.previousSearchText else { return }
|
||||
// discard result when request not the latest one
|
||||
guard id == self.latestLoadingToken else { return }
|
||||
// discard result when state is not Loading
|
||||
guard stateMachine.currentState is Loading else { return }
|
||||
|
||||
let oldItems = _offset == nil ? [] : viewModel.items.value
|
||||
var newItems: [SearchResultItem] = []
|
||||
|
||||
for account in response.value.accounts {
|
||||
let item = SearchResultItem.account(account: account)
|
||||
guard !oldItems.contains(item) else { continue }
|
||||
newItems.append(item)
|
||||
}
|
||||
for hashtag in response.value.hashtags {
|
||||
let item = SearchResultItem.hashtag(tag: hashtag)
|
||||
guard !oldItems.contains(item) else { continue }
|
||||
newItems.append(item)
|
||||
}
|
||||
|
||||
var newStatusIDs = _offset == nil ? [] : viewModel.statusFetchedResultsController.statusIDs.value
|
||||
for status in response.value.statuses {
|
||||
guard !newStatusIDs.contains(status.id) else { continue }
|
||||
newStatusIDs.append(status.id)
|
||||
}
|
||||
|
||||
if viewModel.searchScope == .all || newItems.isEmpty {
|
||||
stateMachine.enter(NoMore.self)
|
||||
} else {
|
||||
stateMachine.enter(Idle.self)
|
||||
}
|
||||
viewModel.items.value = oldItems + newItems
|
||||
viewModel.statusFetchedResultsController.statusIDs.value = newStatusIDs
|
||||
}
|
||||
.store(in: &viewModel.disposeBag)
|
||||
}
|
||||
}
|
||||
|
||||
class Fail: SearchResultViewModel.State {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
switch stateClass {
|
||||
case is Loading.Type:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Idle: SearchResultViewModel.State {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
switch stateClass {
|
||||
case is Loading.Type:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NoMore: SearchResultViewModel.State {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
switch stateClass {
|
||||
case is Loading.Type:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,196 @@
|
|||
//
|
||||
// SearchResultViewModel.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-7-14.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import GameplayKit
|
||||
import CommonOSLog
|
||||
|
||||
final class SearchResultViewModel {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
// input
|
||||
let context: AppContext
|
||||
let searchScope: SearchDetailViewModel.SearchScope
|
||||
let searchText = CurrentValueSubject<String, Never>("")
|
||||
let statusFetchedResultsController: StatusFetchedResultsController
|
||||
let viewDidAppear = CurrentValueSubject<Bool, Never>(false)
|
||||
var cellFrameCache = NSCache<NSNumber, NSValue>()
|
||||
var navigationBarFrame = CurrentValueSubject<CGRect, Never>(.zero)
|
||||
|
||||
// output
|
||||
private(set) lazy var stateMachine: GKStateMachine = {
|
||||
let stateMachine = GKStateMachine(states: [
|
||||
State.Initial(viewModel: self),
|
||||
State.Loading(viewModel: self),
|
||||
State.Fail(viewModel: self),
|
||||
State.Idle(viewModel: self),
|
||||
State.NoMore(viewModel: self),
|
||||
])
|
||||
stateMachine.enter(State.Initial.self)
|
||||
return stateMachine
|
||||
}()
|
||||
let items = CurrentValueSubject<[SearchResultItem], Never>([])
|
||||
var diffableDataSource: UITableViewDiffableDataSource<SearchResultSection, SearchResultItem>!
|
||||
let didDataSourceUpdate = PassthroughSubject<Void, Never>()
|
||||
|
||||
init(context: AppContext, searchScope: SearchDetailViewModel.SearchScope) {
|
||||
self.context = context
|
||||
self.searchScope = searchScope
|
||||
self.statusFetchedResultsController = StatusFetchedResultsController(
|
||||
managedObjectContext: context.managedObjectContext,
|
||||
domain: nil,
|
||||
additionalTweetPredicate: nil
|
||||
)
|
||||
|
||||
context.authenticationService.activeMastodonAuthenticationBox
|
||||
.map { $0?.domain }
|
||||
.assign(to: \.value, on: statusFetchedResultsController.domain)
|
||||
.store(in: &disposeBag)
|
||||
|
||||
Publishers.CombineLatest(
|
||||
items,
|
||||
statusFetchedResultsController.objectIDs.removeDuplicates()
|
||||
)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] items, statusObjectIDs in
|
||||
guard let self = self else { return }
|
||||
guard let diffableDataSource = self.diffableDataSource else { return }
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<SearchResultSection, SearchResultItem>()
|
||||
snapshot.appendSections([.main])
|
||||
|
||||
// append account & hashtag items
|
||||
|
||||
var items = items
|
||||
if self.searchScope == .all {
|
||||
// all search scope not paging. it's safe sort on whole dataset
|
||||
items.sort(by: { ($0.sortKey ?? "") < ($1.sortKey ?? "")})
|
||||
}
|
||||
snapshot.appendItems(items, toSection: .main)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// append statuses
|
||||
var statusItems: [SearchResultItem] = []
|
||||
for objectID in statusObjectIDs {
|
||||
let attribute = oldSnapshotAttributeDict[objectID] ?? Item.StatusAttribute()
|
||||
statusItems.append(.status(statusObjectID: objectID, attribute: attribute))
|
||||
}
|
||||
snapshot.appendItems(statusItems, toSection: .main)
|
||||
|
||||
if let currentState = self.stateMachine.currentState {
|
||||
switch currentState {
|
||||
case is State.Loading, is State.Fail, is State.Idle:
|
||||
let attribute = SearchResultItem.BottomLoaderAttribute(isEmptyResult: false)
|
||||
snapshot.appendItems([.bottomLoader(attribute: attribute)], toSection: .main)
|
||||
case is State.Fail:
|
||||
break
|
||||
case is State.NoMore:
|
||||
if snapshot.itemIdentifiers.isEmpty {
|
||||
let attribute = SearchResultItem.BottomLoaderAttribute(isEmptyResult: true)
|
||||
snapshot.appendItems([.bottomLoader(attribute: attribute)], toSection: .main)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
diffableDataSource.defaultRowAnimation = .fade
|
||||
diffableDataSource.apply(snapshot, animatingDifferences: true) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.didDataSourceUpdate.send()
|
||||
}
|
||||
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension SearchResultViewModel {
|
||||
func setupDiffableDataSource(
|
||||
tableView: UITableView,
|
||||
dependency: NeedsDependency,
|
||||
statusTableViewCellDelegate: StatusTableViewCellDelegate
|
||||
) {
|
||||
diffableDataSource = SearchResultSection.tableViewDiffableDataSource(
|
||||
for: tableView,
|
||||
dependency: dependency,
|
||||
statusTableViewCellDelegate: statusTableViewCellDelegate
|
||||
)
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<SearchResultSection, SearchResultItem>()
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(self.items.value, toSection: .main) // with initial items
|
||||
diffableDataSource.apply(snapshot, animatingDifferences: false)
|
||||
}
|
||||
}
|
||||
|
||||
extension SearchResultViewModel {
|
||||
func persistSearchHistory(for item: SearchResultItem) {
|
||||
guard let box = context.authenticationService.activeMastodonAuthenticationBox.value else { return }
|
||||
let domain = box.domain
|
||||
|
||||
switch item {
|
||||
case .account(let account):
|
||||
let managedObjectContext = context.backgroundManagedObjectContext
|
||||
managedObjectContext.performChanges {
|
||||
let (user, _) = APIService.CoreData.createOrMergeMastodonUser(
|
||||
into: managedObjectContext,
|
||||
for: nil,
|
||||
in: domain,
|
||||
entity: account,
|
||||
userCache: nil,
|
||||
networkDate: Date(),
|
||||
log: OSLog.api
|
||||
)
|
||||
if let searchHistory = user.searchHistory {
|
||||
searchHistory.update(updatedAt: Date())
|
||||
} else {
|
||||
SearchHistory.insert(into: managedObjectContext, account: user)
|
||||
}
|
||||
}
|
||||
.sink { result in
|
||||
// do nothing
|
||||
}
|
||||
.store(in: &context.disposeBag)
|
||||
|
||||
case .hashtag(let hashtag):
|
||||
let managedObjectContext = context.backgroundManagedObjectContext
|
||||
managedObjectContext.performChanges {
|
||||
let (hashtag, _) = APIService.CoreData.createOrMergeTag(
|
||||
into: managedObjectContext,
|
||||
entity: hashtag
|
||||
)
|
||||
if let searchHistory = hashtag.searchHistory {
|
||||
searchHistory.update(updatedAt: Date())
|
||||
} else {
|
||||
SearchHistory.insert(into: managedObjectContext, hashtag: hashtag)
|
||||
}
|
||||
}
|
||||
.sink { result in
|
||||
// do nothing
|
||||
}
|
||||
.store(in: &context.disposeBag)
|
||||
|
||||
case .status:
|
||||
// FIXME:
|
||||
break
|
||||
case .bottomLoader:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
//
|
||||
// SearchingTableViewCell.swift
|
||||
// SearchResultTableViewCell.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/4/2.
|
||||
|
@ -13,7 +13,7 @@ import UIKit
|
|||
import FLAnimatedImage
|
||||
import Nuke
|
||||
|
||||
final class SearchingTableViewCell: UITableViewCell {
|
||||
final class SearchResultTableViewCell: UITableViewCell {
|
||||
|
||||
let _imageView: UIImageView = {
|
||||
let imageView = FLAnimatedImageView()
|
||||
|
@ -37,6 +37,14 @@ final class SearchingTableViewCell: UITableViewCell {
|
|||
label.font = .preferredFont(forTextStyle: .body)
|
||||
return label
|
||||
}()
|
||||
|
||||
let separatorLine = UIView.separatorLine
|
||||
|
||||
var separatorLineToEdgeLeadingLayoutConstraint: NSLayoutConstraint!
|
||||
var separatorLineToEdgeTrailingLayoutConstraint: NSLayoutConstraint!
|
||||
|
||||
var separatorLineToMarginLeadingLayoutConstraint: NSLayoutConstraint!
|
||||
var separatorLineToMarginTrailingLayoutConstraint: NSLayoutConstraint!
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
|
@ -54,28 +62,28 @@ final class SearchingTableViewCell: UITableViewCell {
|
|||
}
|
||||
}
|
||||
|
||||
extension SearchingTableViewCell {
|
||||
extension SearchResultTableViewCell {
|
||||
private func configure() {
|
||||
let containerStackView = UIStackView()
|
||||
containerStackView.axis = .horizontal
|
||||
containerStackView.distribution = .fill
|
||||
containerStackView.spacing = 12
|
||||
containerStackView.layoutMargins = UIEdgeInsets(top: 12, left: 21, bottom: 12, right: 12)
|
||||
containerStackView.layoutMargins = UIEdgeInsets(top: 12, left: 0, bottom: 12, right: 0)
|
||||
containerStackView.isLayoutMarginsRelativeArrangement = true
|
||||
containerStackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(containerStackView)
|
||||
NSLayoutConstraint.activate([
|
||||
containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor),
|
||||
containerStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
||||
containerStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
||||
containerStackView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
|
||||
containerStackView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor),
|
||||
containerStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
|
||||
])
|
||||
|
||||
_imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
containerStackView.addArrangedSubview(_imageView)
|
||||
NSLayoutConstraint.activate([
|
||||
_imageView.widthAnchor.constraint(equalToConstant: 42),
|
||||
_imageView.heightAnchor.constraint(equalToConstant: 42),
|
||||
_imageView.widthAnchor.constraint(equalToConstant: 42).priority(.required - 1),
|
||||
_imageView.heightAnchor.constraint(equalToConstant: 42).priority(.required - 1),
|
||||
])
|
||||
|
||||
let textStackView = UIStackView()
|
||||
|
@ -89,7 +97,62 @@ extension SearchingTableViewCell {
|
|||
_subTitleLabel.setContentHuggingPriority(.defaultLow - 1, for: .vertical)
|
||||
|
||||
containerStackView.addArrangedSubview(textStackView)
|
||||
|
||||
separatorLine.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(separatorLine)
|
||||
separatorLineToEdgeLeadingLayoutConstraint = separatorLine.leadingAnchor.constraint(equalTo: contentView.leadingAnchor)
|
||||
separatorLineToEdgeTrailingLayoutConstraint = separatorLine.trailingAnchor.constraint(equalTo: contentView.trailingAnchor)
|
||||
separatorLineToMarginLeadingLayoutConstraint = separatorLine.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor)
|
||||
separatorLineToMarginTrailingLayoutConstraint = separatorLine.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor)
|
||||
NSLayoutConstraint.activate([
|
||||
separatorLine.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
||||
separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)),
|
||||
])
|
||||
resetSeparatorLineLayout()
|
||||
}
|
||||
|
||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
super.traitCollectionDidChange(previousTraitCollection)
|
||||
|
||||
resetSeparatorLineLayout()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension SearchResultTableViewCell {
|
||||
|
||||
private func resetSeparatorLineLayout() {
|
||||
separatorLineToEdgeLeadingLayoutConstraint.isActive = false
|
||||
separatorLineToEdgeTrailingLayoutConstraint.isActive = false
|
||||
separatorLineToMarginLeadingLayoutConstraint.isActive = false
|
||||
separatorLineToMarginTrailingLayoutConstraint.isActive = false
|
||||
|
||||
if traitCollection.userInterfaceIdiom == .phone {
|
||||
// to edge
|
||||
NSLayoutConstraint.activate([
|
||||
separatorLineToEdgeLeadingLayoutConstraint,
|
||||
separatorLineToEdgeTrailingLayoutConstraint,
|
||||
])
|
||||
} else {
|
||||
if traitCollection.horizontalSizeClass == .compact {
|
||||
// to edge
|
||||
NSLayoutConstraint.activate([
|
||||
separatorLineToEdgeLeadingLayoutConstraint,
|
||||
separatorLineToEdgeTrailingLayoutConstraint,
|
||||
])
|
||||
} else {
|
||||
// to margin
|
||||
NSLayoutConstraint.activate([
|
||||
separatorLineToMarginLeadingLayoutConstraint,
|
||||
separatorLineToMarginTrailingLayoutConstraint,
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension SearchResultTableViewCell {
|
||||
|
||||
func config(with account: Mastodon.Entity.Account) {
|
||||
Nuke.loadImage(
|
||||
|
@ -120,7 +183,7 @@ extension SearchingTableViewCell {
|
|||
func config(with tag: Mastodon.Entity.Tag) {
|
||||
let image = UIImage(systemName: "number.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 34, weight: .regular))!.withRenderingMode(.alwaysTemplate)
|
||||
_imageView.image = image
|
||||
_titleLabel.text = "# " + tag.name
|
||||
_titleLabel.text = "#" + tag.name
|
||||
guard let histories = tag.history else {
|
||||
_subTitleLabel.text = ""
|
||||
return
|
||||
|
@ -151,11 +214,11 @@ extension SearchingTableViewCell {
|
|||
#if canImport(SwiftUI) && DEBUG
|
||||
import SwiftUI
|
||||
|
||||
struct SearchingTableViewCell_Previews: PreviewProvider {
|
||||
struct SearchResultTableViewCell_Previews: PreviewProvider {
|
||||
static var controls: some View {
|
||||
Group {
|
||||
UIViewPreview {
|
||||
let cell = SearchingTableViewCell()
|
||||
let cell = SearchResultTableViewCell()
|
||||
cell.backgroundColor = .white
|
||||
cell._imageView.image = UIImage(systemName: "number.circle.fill")
|
||||
cell._titleLabel.text = "Electronic Frontier Foundation"
|
|
@ -1,93 +0,0 @@
|
|||
//
|
||||
// SearchViewController+Searching.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/4/2.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import Foundation
|
||||
import MastodonSDK
|
||||
import OSLog
|
||||
import UIKit
|
||||
|
||||
extension SearchViewController {
|
||||
func setupSearchingTableView() {
|
||||
searchingTableView.register(SearchingTableViewCell.self, forCellReuseIdentifier: String(describing: SearchingTableViewCell.self))
|
||||
searchingTableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
|
||||
searchingTableView.estimatedRowHeight = 66
|
||||
searchingTableView.rowHeight = 66
|
||||
view.addSubview(searchingTableView)
|
||||
searchingTableView.delegate = self
|
||||
searchingTableView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
searchingTableView.topAnchor.constraint(equalTo: searchBar.bottomAnchor),
|
||||
searchingTableView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
||||
searchingTableView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
||||
searchingTableView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
|
||||
])
|
||||
searchingTableView.tableFooterView = UIView()
|
||||
searchingTableView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
|
||||
viewModel.isSearching
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] isSearching in
|
||||
self?.searchingTableView.isHidden = !isSearching
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
Publishers.CombineLatest(
|
||||
viewModel.isSearching,
|
||||
viewModel.searchText
|
||||
)
|
||||
.sink { [weak self] isSearching, text in
|
||||
guard let self = self else { return }
|
||||
if isSearching, text.isEmpty {
|
||||
self.searchingTableView.tableHeaderView = self.searchHeader
|
||||
} else {
|
||||
self.searchingTableView.tableHeaderView = nil
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
func setupSearchHeader() {
|
||||
let containerStackView = UIStackView()
|
||||
containerStackView.axis = .horizontal
|
||||
containerStackView.distribution = .fill
|
||||
containerStackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
containerStackView.layoutMargins = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
|
||||
containerStackView.isLayoutMarginsRelativeArrangement = true
|
||||
searchHeader.addSubview(containerStackView)
|
||||
NSLayoutConstraint.activate([
|
||||
containerStackView.topAnchor.constraint(equalTo: searchHeader.topAnchor),
|
||||
containerStackView.leadingAnchor.constraint(equalTo: searchHeader.leadingAnchor),
|
||||
containerStackView.trailingAnchor.constraint(equalTo: searchHeader.trailingAnchor),
|
||||
containerStackView.bottomAnchor.constraint(equalTo: searchHeader.bottomAnchor)
|
||||
])
|
||||
recentSearchesLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
containerStackView.addArrangedSubview(recentSearchesLabel)
|
||||
clearSearchHistoryButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
containerStackView.addArrangedSubview(clearSearchHistoryButton)
|
||||
clearSearchHistoryButton.addTarget(self, action: #selector(SearchViewController.clearAction(_:)), for: .touchUpInside)
|
||||
}
|
||||
}
|
||||
|
||||
extension SearchViewController {
|
||||
@objc func clearAction(_ sender: UIButton) {
|
||||
viewModel.deleteSearchHistory()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDelegate
|
||||
|
||||
extension SearchViewController: UITableViewDelegate {
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard let diffableDataSource = viewModel.searchResultDiffableDataSource else { return }
|
||||
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||
viewModel.searchResultItemDidSelected(item: item, from: self)
|
||||
}
|
||||
}
|
|
@ -1,295 +0,0 @@
|
|||
//
|
||||
// SearchViewController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/3/31.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import GameplayKit
|
||||
import MastodonSDK
|
||||
import UIKit
|
||||
|
||||
final class SearchViewController: UIViewController, NeedsDependency {
|
||||
|
||||
public static var hashtagCardHeight: CGFloat {
|
||||
get {
|
||||
if UIScreen.main.bounds.size.height > 736 {
|
||||
return 186
|
||||
}
|
||||
return 130
|
||||
}
|
||||
}
|
||||
|
||||
public static var hashtagPeopleTalkingLabelTop: CGFloat {
|
||||
get {
|
||||
if UIScreen.main.bounds.size.height > 736 {
|
||||
return 18
|
||||
}
|
||||
return 6
|
||||
}
|
||||
}
|
||||
public static let accountCardHeight = 202
|
||||
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
private(set) lazy var viewModel = SearchViewModel(context: context, coordinator: coordinator)
|
||||
|
||||
let statusBar: UIView = {
|
||||
let view = UIView()
|
||||
return view
|
||||
}()
|
||||
|
||||
let searchBar: UISearchBar = {
|
||||
let searchBar = UISearchBar()
|
||||
searchBar.placeholder = L10n.Scene.Search.Searchbar.placeholder
|
||||
searchBar.translatesAutoresizingMaskIntoConstraints = false
|
||||
// let micImage = UIImage(systemName: "mic.fill")
|
||||
// searchBar.setImage(micImage, for: .bookmark, state: .normal)
|
||||
// searchBar.showsBookmarkButton = true
|
||||
searchBar.scopeButtonTitles = [L10n.Scene.Search.Searching.Segment.all, L10n.Scene.Search.Searching.Segment.people, L10n.Scene.Search.Searching.Segment.hashtags]
|
||||
return searchBar
|
||||
}()
|
||||
|
||||
// recommend
|
||||
let scrollView: UIScrollView = {
|
||||
let scrollView = UIScrollView()
|
||||
scrollView.showsVerticalScrollIndicator = false
|
||||
scrollView.alwaysBounceVertical = true
|
||||
scrollView.clipsToBounds = false
|
||||
return scrollView
|
||||
}()
|
||||
|
||||
let stackView: UIStackView = {
|
||||
let stackView = UIStackView()
|
||||
stackView.axis = .vertical
|
||||
stackView.distribution = .fill
|
||||
return stackView
|
||||
}()
|
||||
|
||||
let hashtagCollectionView: UICollectionView = {
|
||||
let flowLayout = UICollectionViewFlowLayout()
|
||||
flowLayout.scrollDirection = .horizontal
|
||||
let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout)
|
||||
view.backgroundColor = .clear
|
||||
view.showsHorizontalScrollIndicator = false
|
||||
view.showsVerticalScrollIndicator = false
|
||||
view.layer.masksToBounds = false
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
return view
|
||||
}()
|
||||
|
||||
let accountsCollectionView: UICollectionView = {
|
||||
let flowLayout = UICollectionViewFlowLayout()
|
||||
flowLayout.scrollDirection = .horizontal
|
||||
let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout)
|
||||
view.backgroundColor = .clear
|
||||
view.showsHorizontalScrollIndicator = false
|
||||
view.showsVerticalScrollIndicator = false
|
||||
view.layer.masksToBounds = false
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
return view
|
||||
}()
|
||||
|
||||
// searching
|
||||
let searchingTableView: UITableView = {
|
||||
let tableView = UITableView()
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.separatorStyle = .singleLine
|
||||
tableView.separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
|
||||
tableView.separatorColor = UIView.separatorColor
|
||||
return tableView
|
||||
}()
|
||||
|
||||
lazy var searchHeader: UIView = {
|
||||
let view = UIView()
|
||||
view.frame = CGRect(origin: .zero, size: CGSize(width: searchingTableView.frame.width, height: 56))
|
||||
return view
|
||||
}()
|
||||
|
||||
let recentSearchesLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold))
|
||||
label.textColor = Asset.Colors.Label.primary.color
|
||||
label.text = L10n.Scene.Search.Searching.recentSearch
|
||||
return label
|
||||
}()
|
||||
|
||||
let clearSearchHistoryButton: HighlightDimmableButton = {
|
||||
let button = HighlightDimmableButton(type: .custom)
|
||||
button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal)
|
||||
button.setTitle(L10n.Scene.Search.Searching.clear, for: .normal)
|
||||
return button
|
||||
}()
|
||||
}
|
||||
|
||||
extension SearchViewController {
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
let barAppearance = UINavigationBarAppearance()
|
||||
barAppearance.configureWithTransparentBackground()
|
||||
navigationItem.standardAppearance = barAppearance
|
||||
navigationItem.compactAppearance = barAppearance
|
||||
navigationItem.scrollEdgeAppearance = barAppearance
|
||||
|
||||
setupBackgroundColor(theme: ThemeService.shared.currentTheme.value)
|
||||
ThemeService.shared.currentTheme
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] theme in
|
||||
guard let self = self else { return }
|
||||
self.setupBackgroundColor(theme: theme)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
navigationItem.hidesBackButton = true
|
||||
|
||||
setupSearchBar()
|
||||
setupScrollView()
|
||||
setupHashTagCollectionView()
|
||||
setupAccountsCollectionView()
|
||||
setupSearchingTableView()
|
||||
setupDataSource()
|
||||
setupSearchHeader()
|
||||
view.bringSubviewToFront(searchBar)
|
||||
view.bringSubviewToFront(statusBar)
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
viewModel.viewDidAppeared.send()
|
||||
}
|
||||
|
||||
private func setupBackgroundColor(theme: Theme) {
|
||||
view.backgroundColor = theme.systemGroupedBackgroundColor
|
||||
searchHeader.backgroundColor = theme.systemGroupedBackgroundColor
|
||||
searchingTableView.backgroundColor = theme.systemBackgroundColor
|
||||
statusBar.backgroundColor = theme.navigationBarBackgroundColor
|
||||
}
|
||||
|
||||
func setupSearchBar() {
|
||||
searchBar.delegate = self
|
||||
view.addSubview(searchBar)
|
||||
searchBar.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
searchBar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||
searchBar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
searchBar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
])
|
||||
|
||||
statusBar.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(statusBar)
|
||||
NSLayoutConstraint.activate([
|
||||
statusBar.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
statusBar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
statusBar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
statusBar.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: 3),
|
||||
])
|
||||
}
|
||||
|
||||
func setupScrollView() {
|
||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
// scrollView
|
||||
view.addSubview(scrollView)
|
||||
NSLayoutConstraint.activate([
|
||||
scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: searchBar.frame.height),
|
||||
scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
view.trailingAnchor.constraint(equalTo: scrollView.frameLayoutGuide.trailingAnchor),
|
||||
scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
scrollView.frameLayoutGuide.widthAnchor.constraint(equalTo: scrollView.contentLayoutGuide.widthAnchor),
|
||||
])
|
||||
|
||||
// stackview
|
||||
scrollView.addSubview(stackView)
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
|
||||
stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
|
||||
stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
|
||||
stackView.widthAnchor.constraint(equalTo: scrollView.contentLayoutGuide.widthAnchor),
|
||||
scrollView.contentLayoutGuide.bottomAnchor.constraint(equalTo: stackView.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
func setupDataSource() {
|
||||
viewModel.hashtagDiffableDataSource = RecommendHashTagSection.collectionViewDiffableDataSource(for: hashtagCollectionView)
|
||||
viewModel.accountDiffableDataSource = RecommendAccountSection.collectionViewDiffableDataSource(for: accountsCollectionView, delegate: self, managedObjectContext: context.managedObjectContext)
|
||||
viewModel.searchResultDiffableDataSource = SearchResultSection.tableViewDiffableDataSource(for: searchingTableView, dependency: self)
|
||||
}
|
||||
}
|
||||
|
||||
extension SearchViewController: UIScrollViewDelegate {
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
if scrollView == searchingTableView {
|
||||
handleScrollViewDidScroll(scrollView)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SearchViewController: UISearchBarDelegate {
|
||||
func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
|
||||
searchBar.setShowsCancelButton(true, animated: true)
|
||||
searchBar.showsScopeBar = true
|
||||
viewModel.isSearching.value = true
|
||||
}
|
||||
|
||||
func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
|
||||
searchBar.setShowsCancelButton(false, animated: true)
|
||||
searchBar.showsScopeBar = false
|
||||
viewModel.isSearching.value = true
|
||||
}
|
||||
|
||||
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
|
||||
searchBar.setShowsCancelButton(false, animated: true)
|
||||
searchBar.showsScopeBar = false
|
||||
searchBar.text = ""
|
||||
searchBar.resignFirstResponder()
|
||||
viewModel.isSearching.value = false
|
||||
}
|
||||
|
||||
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
|
||||
viewModel.searchText.send(searchText)
|
||||
}
|
||||
|
||||
func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) {
|
||||
switch selectedScope {
|
||||
case 0:
|
||||
viewModel.searchScope.value = Mastodon.API.V2.Search.SearchType.default
|
||||
case 1:
|
||||
viewModel.searchScope.value = Mastodon.API.V2.Search.SearchType.accounts
|
||||
case 2:
|
||||
viewModel.searchScope.value = Mastodon.API.V2.Search.SearchType.hashtags
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func searchBarBookmarkButtonClicked(_ searchBar: UISearchBar) {}
|
||||
}
|
||||
|
||||
extension SearchViewController: LoadMoreConfigurableTableViewContainer {
|
||||
typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell
|
||||
typealias LoadingState = SearchViewModel.LoadOldestState.Loading
|
||||
var loadMoreConfigurableTableView: UITableView { searchingTableView }
|
||||
var loadMoreConfigurableStateMachine: GKStateMachine { viewModel.loadoldestStateMachine }
|
||||
}
|
||||
|
||||
#if canImport(SwiftUI) && DEBUG
|
||||
import SwiftUI
|
||||
|
||||
struct SearchViewController_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
UIViewControllerPreview {
|
||||
let viewController = SearchViewController()
|
||||
return viewController
|
||||
}
|
||||
.previewLayout(.fixed(width: 375, height: 800))
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
|
@ -1,146 +0,0 @@
|
|||
//
|
||||
// SearchViewModel+LoadOldestState.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/4/6.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import GameplayKit
|
||||
import MastodonSDK
|
||||
import os.log
|
||||
|
||||
extension SearchViewModel {
|
||||
class LoadOldestState: GKState {
|
||||
weak var viewModel: SearchViewModel?
|
||||
|
||||
init(viewModel: SearchViewModel) {
|
||||
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, debugDescription, previousState.debugDescription)
|
||||
viewModel?.loadOldestStateMachinePublisher.send(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SearchViewModel.LoadOldestState {
|
||||
class Initial: SearchViewModel.LoadOldestState {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
guard let viewModel = viewModel else { return false }
|
||||
guard viewModel.searchResult.value != nil else { return false }
|
||||
return stateClass == Loading.self
|
||||
}
|
||||
}
|
||||
|
||||
class Loading: SearchViewModel.LoadOldestState {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
stateClass == Fail.self || stateClass == Idle.self || stateClass == NoMore.self
|
||||
}
|
||||
|
||||
override func didEnter(from previousState: GKState?) {
|
||||
super.didEnter(from: previousState)
|
||||
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
||||
guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||
assertionFailure()
|
||||
stateMachine.enter(Fail.self)
|
||||
return
|
||||
}
|
||||
guard let oldSearchResult = viewModel.searchResult.value else {
|
||||
stateMachine.enter(Fail.self)
|
||||
return
|
||||
}
|
||||
var offset = 0
|
||||
switch viewModel.searchScope.value {
|
||||
case Mastodon.API.V2.Search.SearchType.accounts:
|
||||
offset = oldSearchResult.accounts.count
|
||||
case Mastodon.API.V2.Search.SearchType.hashtags:
|
||||
offset = oldSearchResult.hashtags.count
|
||||
default:
|
||||
return
|
||||
}
|
||||
let query = Mastodon.API.V2.Search.Query(q: viewModel.searchText.value,
|
||||
type: viewModel.searchScope.value,
|
||||
accountID: nil,
|
||||
maxID: nil,
|
||||
minID: nil,
|
||||
excludeUnreviewed: nil,
|
||||
resolve: nil,
|
||||
limit: nil,
|
||||
offset: offset,
|
||||
following: nil)
|
||||
|
||||
viewModel.context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
|
||||
.sink { completion in
|
||||
switch completion {
|
||||
case .failure(let error):
|
||||
os_log("%{public}s[%{public}ld], %{public}s: load oldest search failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
|
||||
case .finished:
|
||||
// handle isFetchingLatestTimeline in fetch controller delegate
|
||||
break
|
||||
}
|
||||
} receiveValue: { result in
|
||||
switch viewModel.searchScope.value {
|
||||
case Mastodon.API.V2.Search.SearchType.accounts:
|
||||
if result.value.accounts.isEmpty {
|
||||
stateMachine.enter(NoMore.self)
|
||||
} else {
|
||||
var newAccounts = [Mastodon.Entity.Account]()
|
||||
newAccounts.append(contentsOf: oldSearchResult.accounts)
|
||||
newAccounts.append(contentsOf: result.value.accounts)
|
||||
newAccounts.removeDuplicates()
|
||||
viewModel.searchResult.value = Mastodon.Entity.SearchResult(accounts: newAccounts, statuses: oldSearchResult.statuses, hashtags: oldSearchResult.hashtags)
|
||||
stateMachine.enter(Idle.self)
|
||||
}
|
||||
case Mastodon.API.V2.Search.SearchType.hashtags:
|
||||
if result.value.hashtags.isEmpty {
|
||||
stateMachine.enter(NoMore.self)
|
||||
} else {
|
||||
var newTags = [Mastodon.Entity.Tag]()
|
||||
newTags.append(contentsOf: oldSearchResult.hashtags)
|
||||
newTags.append(contentsOf: result.value.hashtags)
|
||||
newTags.removeDuplicates()
|
||||
viewModel.searchResult.value = Mastodon.Entity.SearchResult(accounts: oldSearchResult.accounts, statuses: oldSearchResult.statuses, hashtags: newTags)
|
||||
stateMachine.enter(Idle.self)
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
.store(in: &viewModel.disposeBag)
|
||||
}
|
||||
}
|
||||
|
||||
class Fail: SearchViewModel.LoadOldestState {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
stateClass == Loading.self || stateClass == Idle.self
|
||||
}
|
||||
}
|
||||
|
||||
class Idle: SearchViewModel.LoadOldestState {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
stateClass == Loading.self
|
||||
}
|
||||
}
|
||||
|
||||
class NoMore: SearchViewModel.LoadOldestState {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
// reset state if needs
|
||||
stateClass == Idle.self
|
||||
}
|
||||
|
||||
override func didEnter(from previousState: GKState?) {
|
||||
guard let viewModel = viewModel else { return }
|
||||
guard let diffableDataSource = viewModel.searchResultDiffableDataSource else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
var snapshot = diffableDataSource.snapshot()
|
||||
snapshot.deleteItems([.bottomLoader])
|
||||
diffableDataSource.apply(snapshot)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,435 +0,0 @@
|
|||
//
|
||||
// SearchViewModel.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/3/31.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import Foundation
|
||||
import GameplayKit
|
||||
import MastodonSDK
|
||||
import OSLog
|
||||
import UIKit
|
||||
|
||||
final class SearchViewModel: NSObject {
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
// input
|
||||
let context: AppContext
|
||||
weak var coordinator: SceneCoordinator!
|
||||
|
||||
let currentMastodonUser = CurrentValueSubject<MastodonUser?, Never>(nil)
|
||||
let viewDidAppeared = PassthroughSubject<Void, Never>()
|
||||
|
||||
// output
|
||||
let searchText = CurrentValueSubject<String, Never>("")
|
||||
let searchScope = CurrentValueSubject<Mastodon.API.V2.Search.SearchType, Never>(Mastodon.API.V2.Search.SearchType.default)
|
||||
|
||||
let isSearching = CurrentValueSubject<Bool, Never>(false)
|
||||
|
||||
let searchResult = CurrentValueSubject<Mastodon.Entity.SearchResult?, Never>(nil)
|
||||
|
||||
// var recommendHashTags = [Mastodon.Entity.Tag]()
|
||||
var recommendAccounts = [NSManagedObjectID]()
|
||||
var recommendAccountsFallback = PassthroughSubject<Void, Never>()
|
||||
|
||||
var hashtagDiffableDataSource: UICollectionViewDiffableDataSource<RecommendHashTagSection, Mastodon.Entity.Tag>?
|
||||
var accountDiffableDataSource: UICollectionViewDiffableDataSource<RecommendAccountSection, NSManagedObjectID>?
|
||||
var searchResultDiffableDataSource: UITableViewDiffableDataSource<SearchResultSection, SearchResultItem>?
|
||||
|
||||
let statusFetchedResultsController: StatusFetchedResultsController
|
||||
|
||||
// bottom loader
|
||||
private(set) lazy var loadoldestStateMachine: GKStateMachine = {
|
||||
// exclude timeline middle fetcher state
|
||||
let stateMachine = GKStateMachine(states: [
|
||||
LoadOldestState.Initial(viewModel: self),
|
||||
LoadOldestState.Loading(viewModel: self),
|
||||
LoadOldestState.Fail(viewModel: self),
|
||||
LoadOldestState.Idle(viewModel: self),
|
||||
LoadOldestState.NoMore(viewModel: self),
|
||||
])
|
||||
stateMachine.enter(LoadOldestState.Initial.self)
|
||||
return stateMachine
|
||||
}()
|
||||
|
||||
lazy var loadOldestStateMachinePublisher = CurrentValueSubject<LoadOldestState?, Never>(nil)
|
||||
|
||||
init(context: AppContext, coordinator: SceneCoordinator) {
|
||||
self.coordinator = coordinator
|
||||
self.context = context
|
||||
self.statusFetchedResultsController = StatusFetchedResultsController(
|
||||
managedObjectContext: context.managedObjectContext,
|
||||
domain: nil,
|
||||
additionalTweetPredicate: nil
|
||||
)
|
||||
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.currentMastodonUser.value = nil
|
||||
return
|
||||
}
|
||||
self.currentMastodonUser.value = activeMastodonAuthentication.user
|
||||
self.statusFetchedResultsController.domain.value = activeMastodonAuthentication.domain
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
Publishers.CombineLatest(
|
||||
searchText
|
||||
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main).removeDuplicates(),
|
||||
searchScope
|
||||
)
|
||||
.filter { text, _ in
|
||||
!text.isEmpty
|
||||
}
|
||||
.compactMap { (text, scope) -> AnyPublisher<Result<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error>, Never>? in
|
||||
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return nil }
|
||||
let query = Mastodon.API.V2.Search.Query(
|
||||
q: text,
|
||||
type: scope,
|
||||
accountID: nil,
|
||||
maxID: nil,
|
||||
minID: nil,
|
||||
excludeUnreviewed: nil,
|
||||
resolve: nil,
|
||||
limit: nil,
|
||||
offset: nil,
|
||||
following: nil
|
||||
)
|
||||
return context.apiService.search(
|
||||
domain: activeMastodonAuthenticationBox.domain,
|
||||
query: query,
|
||||
mastodonAuthenticationBox: activeMastodonAuthenticationBox
|
||||
)
|
||||
// .retry(3) // iOS 14.0 SDK may not works here. needs testing before add this
|
||||
.map { response in Result<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> { response } }
|
||||
.catch { error in Just(Result<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> { throw error }) }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.switchToLatest()
|
||||
.sink { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
switch result {
|
||||
case .success(let response):
|
||||
guard self.isSearching.value else { return }
|
||||
self.searchResult.value = response.value
|
||||
case .failure(let error):
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
isSearching
|
||||
.sink { [weak self] isSearching in
|
||||
if !isSearching {
|
||||
self?.searchResult.value = nil
|
||||
self?.searchText.value = ""
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
Publishers.CombineLatest3(
|
||||
isSearching,
|
||||
searchText,
|
||||
searchScope
|
||||
)
|
||||
.filter { isSearching, _, _ in
|
||||
isSearching
|
||||
}
|
||||
.sink { [weak self] _, text, scope in
|
||||
guard text.isEmpty else { return }
|
||||
guard let self = self else { return }
|
||||
guard let searchHistories = self.fetchSearchHistory() else { return }
|
||||
guard let dataSource = self.searchResultDiffableDataSource else { return }
|
||||
var snapshot = NSDiffableDataSourceSnapshot<SearchResultSection, SearchResultItem>()
|
||||
snapshot.appendSections([.mixed])
|
||||
|
||||
searchHistories.forEach { searchHistory in
|
||||
let containsAccount = scope == Mastodon.API.V2.Search.SearchType.accounts || scope == Mastodon.API.V2.Search.SearchType.default
|
||||
let containsHashTag = scope == Mastodon.API.V2.Search.SearchType.hashtags || scope == Mastodon.API.V2.Search.SearchType.default
|
||||
if let mastodonUser = searchHistory.account, containsAccount {
|
||||
let item = SearchResultItem.accountObjectID(accountObjectID: mastodonUser.objectID)
|
||||
snapshot.appendItems([item], toSection: .mixed)
|
||||
}
|
||||
if let tag = searchHistory.hashtag, containsHashTag {
|
||||
let item = SearchResultItem.hashtagObjectID(hashtagObjectID: tag.objectID)
|
||||
snapshot.appendItems([item], toSection: .mixed)
|
||||
}
|
||||
}
|
||||
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
Publishers.CombineLatest(
|
||||
context.authenticationService.activeMastodonAuthenticationBox,
|
||||
viewDidAppeared
|
||||
)
|
||||
.compactMap { activeMastodonAuthenticationBox, _ -> AuthenticationService.MastodonAuthenticationBox? in
|
||||
return activeMastodonAuthenticationBox
|
||||
}
|
||||
.throttle(for: 1, scheduler: DispatchQueue.main, latest: false)
|
||||
.flatMap { box in
|
||||
context.apiService.recommendTrends(domain: box.domain, query: nil)
|
||||
.map { response in Result<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, Error> { response } }
|
||||
.catch { error in Just(Result<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, Error> { throw error }) }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
switch result {
|
||||
case .success(let response):
|
||||
guard let dataSource = self.hashtagDiffableDataSource else { return }
|
||||
var snapshot = NSDiffableDataSourceSnapshot<RecommendHashTagSection, Mastodon.Entity.Tag>()
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(response.value, toSection: .main)
|
||||
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
|
||||
case .failure(let error):
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
Publishers.CombineLatest(
|
||||
context.authenticationService.activeMastodonAuthenticationBox,
|
||||
viewDidAppeared
|
||||
)
|
||||
.compactMap { activeMastodonAuthenticationBox, _ -> AuthenticationService.MastodonAuthenticationBox? in
|
||||
return activeMastodonAuthenticationBox
|
||||
}
|
||||
.throttle(for: 1, scheduler: DispatchQueue.main, latest: false)
|
||||
.flatMap { box -> AnyPublisher<Result<[Mastodon.Entity.Account.ID], Error>, Never> in
|
||||
context.apiService.suggestionAccountV2(domain: box.domain, query: nil, mastodonAuthenticationBox: box)
|
||||
.map { response in Result<[Mastodon.Entity.Account.ID], Error> { response.value.map { $0.account.id } } }
|
||||
.catch { error -> AnyPublisher<Result<[Mastodon.Entity.Account.ID], Error>, Never> in
|
||||
if let apiError = error as? Mastodon.API.Error, apiError.httpResponseStatus == .notFound {
|
||||
return context.apiService.suggestionAccount(domain: box.domain, query: nil, mastodonAuthenticationBox: box)
|
||||
.map { response in Result<[Mastodon.Entity.Account.ID], Error> { response.value.map { $0.id } } }
|
||||
.catch { error in Just(Result<[Mastodon.Entity.Account.ID], Error> { throw error }) }
|
||||
.eraseToAnyPublisher()
|
||||
} else {
|
||||
return Just(Result<[Mastodon.Entity.Account.ID], Error> { throw error })
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
switch result {
|
||||
case .success(let userIDs):
|
||||
self.receiveAccounts(ids: userIDs)
|
||||
case .failure(let error):
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
searchResult
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] searchResult in
|
||||
guard let self = self else { return }
|
||||
guard let dataSource = self.searchResultDiffableDataSource else { return }
|
||||
var snapshot = NSDiffableDataSourceSnapshot<SearchResultSection, SearchResultItem>()
|
||||
if let accounts = searchResult?.accounts {
|
||||
snapshot.appendSections([.account])
|
||||
let items = accounts.compactMap { SearchResultItem.account(account: $0) }
|
||||
snapshot.appendItems(items, toSection: .account)
|
||||
if self.searchScope.value == Mastodon.API.V2.Search.SearchType.accounts, !items.isEmpty {
|
||||
snapshot.appendItems([.bottomLoader], toSection: .account)
|
||||
}
|
||||
}
|
||||
if let tags = searchResult?.hashtags {
|
||||
snapshot.appendSections([.hashtag])
|
||||
let items = tags.compactMap { SearchResultItem.hashtag(tag: $0) }
|
||||
snapshot.appendItems(items, toSection: .hashtag)
|
||||
if self.searchScope.value == Mastodon.API.V2.Search.SearchType.hashtags, !items.isEmpty {
|
||||
snapshot.appendItems([.bottomLoader], toSection: .hashtag)
|
||||
}
|
||||
}
|
||||
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
func receiveAccounts(ids: [Mastodon.Entity.Account.ID]) {
|
||||
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||
return
|
||||
}
|
||||
let userFetchRequest = MastodonUser.sortedFetchRequest
|
||||
userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids)
|
||||
let mastodonUsers: [MastodonUser]? = {
|
||||
let userFetchRequest = MastodonUser.sortedFetchRequest
|
||||
userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids)
|
||||
userFetchRequest.returnsObjectsAsFaults = false
|
||||
do {
|
||||
return try self.context.managedObjectContext.fetch(userFetchRequest)
|
||||
} catch {
|
||||
assertionFailure(error.localizedDescription)
|
||||
return nil
|
||||
}
|
||||
}()
|
||||
guard let users = mastodonUsers else { return }
|
||||
let objectIDs: [NSManagedObjectID] = users
|
||||
.compactMap { object in
|
||||
ids.firstIndex(of: object.id).map { index in (index, object) }
|
||||
}
|
||||
.sorted { $0.0 < $1.0 }
|
||||
.map { $0.1.objectID }
|
||||
|
||||
// append at front
|
||||
let newObjectIDs = objectIDs.filter { !self.recommendAccounts.contains($0) }
|
||||
self.recommendAccounts = newObjectIDs + self.recommendAccounts
|
||||
|
||||
guard let dataSource = self.accountDiffableDataSource else { return }
|
||||
var snapshot = NSDiffableDataSourceSnapshot<RecommendAccountSection, NSManagedObjectID>()
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(self.recommendAccounts, toSection: .main)
|
||||
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
|
||||
}
|
||||
|
||||
func accountCollectionViewItemDidSelected(mastodonUser: MastodonUser, from: UIViewController) {
|
||||
let viewModel = ProfileViewModel(context: context, optionalMastodonUser: mastodonUser)
|
||||
DispatchQueue.main.async {
|
||||
self.coordinator.present(scene: .profile(viewModel: viewModel), from: from, transition: .show)
|
||||
}
|
||||
}
|
||||
|
||||
func hashtagCollectionViewItemDidSelected(hashtag: Mastodon.Entity.Tag, from: UIViewController) {
|
||||
let (tagInCoreData, _) = APIService.CoreData.createOrMergeTag(into: context.managedObjectContext, entity: hashtag)
|
||||
let viewModel = HashtagTimelineViewModel(context: context, hashtag: tagInCoreData.name)
|
||||
DispatchQueue.main.async {
|
||||
self.coordinator.present(scene: .hashtagTimeline(viewModel: viewModel), from: from, transition: .show)
|
||||
}
|
||||
}
|
||||
|
||||
func searchResultItemDidSelected(item: SearchResultItem, from: UIViewController) {
|
||||
let searchHistories = fetchSearchHistory()
|
||||
_ = context.managedObjectContext.performChanges { [weak self] in
|
||||
guard let self = self else { return }
|
||||
switch item {
|
||||
case .account(let account):
|
||||
guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||
return
|
||||
}
|
||||
// load request mastodon user
|
||||
let requestMastodonUser: MastodonUser? = {
|
||||
let request = MastodonUser.sortedFetchRequest
|
||||
request.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, id: activeMastodonAuthenticationBox.userID)
|
||||
request.fetchLimit = 1
|
||||
request.returnsObjectsAsFaults = false
|
||||
do {
|
||||
return try self.context.managedObjectContext.fetch(request).first
|
||||
} catch {
|
||||
assertionFailure(error.localizedDescription)
|
||||
return nil
|
||||
}
|
||||
}()
|
||||
let (mastodonUser, _) = APIService.CoreData.createOrMergeMastodonUser(into: self.context.managedObjectContext, for: requestMastodonUser, in: activeMastodonAuthenticationBox.domain, entity: account, userCache: nil, networkDate: Date(), log: OSLog.api)
|
||||
if let searchHistories = searchHistories {
|
||||
let history = searchHistories.first { history -> Bool in
|
||||
guard let account = history.account else { return false }
|
||||
return account.objectID == mastodonUser.objectID
|
||||
}
|
||||
if let history = history {
|
||||
history.update(updatedAt: Date())
|
||||
} else {
|
||||
SearchHistory.insert(into: self.context.managedObjectContext, account: mastodonUser)
|
||||
}
|
||||
} else {
|
||||
SearchHistory.insert(into: self.context.managedObjectContext, account: mastodonUser)
|
||||
}
|
||||
let viewModel = ProfileViewModel(context: self.context, optionalMastodonUser: mastodonUser)
|
||||
DispatchQueue.main.async {
|
||||
self.coordinator.present(scene: .profile(viewModel: viewModel), from: from, transition: .show)
|
||||
}
|
||||
|
||||
case .hashtag(let tag):
|
||||
let (tagInCoreData, _) = APIService.CoreData.createOrMergeTag(into: self.context.managedObjectContext, entity: tag)
|
||||
if let searchHistories = searchHistories {
|
||||
let history = searchHistories.first { history -> Bool in
|
||||
guard let hashtag = history.hashtag else { return false }
|
||||
return hashtag.objectID == tagInCoreData.objectID
|
||||
}
|
||||
if let history = history {
|
||||
history.update(updatedAt: Date())
|
||||
} else {
|
||||
SearchHistory.insert(into: self.context.managedObjectContext, hashtag: tagInCoreData)
|
||||
}
|
||||
} else {
|
||||
SearchHistory.insert(into: self.context.managedObjectContext, hashtag: tagInCoreData)
|
||||
}
|
||||
let viewModel = HashtagTimelineViewModel(context: self.context, hashtag: tagInCoreData.name)
|
||||
DispatchQueue.main.async {
|
||||
self.coordinator.present(scene: .hashtagTimeline(viewModel: viewModel), from: from, transition: .show)
|
||||
}
|
||||
case .accountObjectID(let accountObjectID):
|
||||
if let searchHistories = searchHistories {
|
||||
let history = searchHistories.first { history -> Bool in
|
||||
guard let account = history.account else { return false }
|
||||
return account.objectID == accountObjectID
|
||||
}
|
||||
if let history = history {
|
||||
history.update(updatedAt: Date())
|
||||
}
|
||||
}
|
||||
let mastodonUser = self.context.managedObjectContext.object(with: accountObjectID) as! MastodonUser
|
||||
let viewModel = ProfileViewModel(context: self.context, optionalMastodonUser: mastodonUser)
|
||||
DispatchQueue.main.async {
|
||||
self.coordinator.present(scene: .profile(viewModel: viewModel), from: from, transition: .show)
|
||||
}
|
||||
case .hashtagObjectID(let hashtagObjectID):
|
||||
if let searchHistories = searchHistories {
|
||||
let history = searchHistories.first { history -> Bool in
|
||||
guard let hashtag = history.hashtag else { return false }
|
||||
return hashtag.objectID == hashtagObjectID
|
||||
}
|
||||
if let history = history {
|
||||
history.update(updatedAt: Date())
|
||||
}
|
||||
}
|
||||
let tagInCoreData = self.context.managedObjectContext.object(with: hashtagObjectID) as! Tag
|
||||
let viewModel = HashtagTimelineViewModel(context: self.context, hashtag: tagInCoreData.name)
|
||||
DispatchQueue.main.async {
|
||||
self.coordinator.present(scene: .hashtagTimeline(viewModel: viewModel), from: from, transition: .show)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchSearchHistory() -> [SearchHistory]? {
|
||||
let searchHistory: [SearchHistory]? = {
|
||||
let request = SearchHistory.sortedFetchRequest
|
||||
request.predicate = nil
|
||||
request.returnsObjectsAsFaults = false
|
||||
do {
|
||||
return try context.managedObjectContext.fetch(request)
|
||||
} catch {
|
||||
assertionFailure(error.localizedDescription)
|
||||
return nil
|
||||
}
|
||||
|
||||
}()
|
||||
return searchHistory
|
||||
}
|
||||
|
||||
func deleteSearchHistory() {
|
||||
let result = fetchSearchHistory()
|
||||
_ = context.managedObjectContext.performChanges { [weak self] in
|
||||
result?.forEach { history in
|
||||
self?.context.managedObjectContext.delete(history)
|
||||
}
|
||||
self?.isSearching.value = true
|
||||
}
|
||||
}
|
||||
}
|
|
@ -12,7 +12,7 @@ import ActiveLabel
|
|||
import CoreData
|
||||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
|
||||
import AuthenticationServices
|
||||
|
||||
class SettingsViewController: UIViewController, NeedsDependency {
|
||||
|
||||
|
@ -358,7 +358,7 @@ extension SettingsViewController: UITableViewDelegate {
|
|||
case .appearance:
|
||||
// do nothing
|
||||
break
|
||||
case .appearanceDarkMode, .appearanceDisableAvatarAnimation:
|
||||
case .preferenceDarkMode, .preferenceDisableAvatarAnimation:
|
||||
// do nothing
|
||||
break
|
||||
case .notification:
|
||||
|
@ -369,6 +369,10 @@ extension SettingsViewController: UITableViewDelegate {
|
|||
break
|
||||
case .boringZone(let link), .spicyZone(let link):
|
||||
switch link {
|
||||
case .accountSettings:
|
||||
guard let box = context.authenticationService.activeMastodonAuthenticationBox.value,
|
||||
let url = URL(string: "https://\(box.domain)/auth/edit") else { return }
|
||||
viewModel.openAuthenticationPage(authenticateURL: url, presentationContextProvider: self)
|
||||
case .termsOfService, .privacyPolicy:
|
||||
// same URL
|
||||
guard let url = viewModel.privacyURL else { break }
|
||||
|
@ -382,10 +386,10 @@ extension SettingsViewController: UITableViewDelegate {
|
|||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] byteCount in
|
||||
guard let self = self else { return }
|
||||
let byteCountformatted = AppContext.byteCountFormatter.string(fromByteCount: Int64(byteCount))
|
||||
let byteCountFormatted = AppContext.byteCountFormatter.string(fromByteCount: Int64(byteCount))
|
||||
let alertController = UIAlertController(
|
||||
title: L10n.Common.Alerts.CleanCache.title,
|
||||
message: L10n.Common.Alerts.CleanCache.message(byteCountformatted),
|
||||
message: L10n.Common.Alerts.CleanCache.message(byteCountFormatted),
|
||||
preferredStyle: .alert
|
||||
)
|
||||
let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil)
|
||||
|
@ -453,38 +457,6 @@ extension SettingsViewController: SettingsToggleCellDelegate {
|
|||
let item = dataSource.itemIdentifier(for: indexPath)
|
||||
|
||||
switch item {
|
||||
case .appearanceDarkMode(let settingObjectID):
|
||||
let managedObjectContext = context.backgroundManagedObjectContext
|
||||
managedObjectContext.performChanges {
|
||||
let setting = managedObjectContext.object(with: settingObjectID) as! Setting
|
||||
setting.update(preferredTrueBlackDarkMode: isOn)
|
||||
}
|
||||
.sink { result in
|
||||
switch result {
|
||||
case .success:
|
||||
ThemeService.shared.set(themeName: isOn ? .system : .mastodon)
|
||||
case .failure(let error):
|
||||
assertionFailure(error.localizedDescription)
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
case .appearanceDisableAvatarAnimation(let settingObjectID):
|
||||
let managedObjectContext = context.backgroundManagedObjectContext
|
||||
managedObjectContext.performChanges {
|
||||
let setting = managedObjectContext.object(with: settingObjectID) as! Setting
|
||||
setting.update(preferredStaticAvatar: isOn)
|
||||
}
|
||||
.sink { result in
|
||||
switch result {
|
||||
case .success:
|
||||
UserDefaults.shared.preferredStaticAvatar = isOn
|
||||
case .failure(let error):
|
||||
assertionFailure(error.localizedDescription)
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
case .notification(let settingObjectID, let switchMode):
|
||||
let managedObjectContext = context.backgroundManagedObjectContext
|
||||
managedObjectContext.performChanges {
|
||||
|
@ -504,6 +476,38 @@ extension SettingsViewController: SettingsToggleCellDelegate {
|
|||
// do nothing
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
case .preferenceDarkMode(let settingObjectID):
|
||||
let managedObjectContext = context.backgroundManagedObjectContext
|
||||
managedObjectContext.performChanges {
|
||||
let setting = managedObjectContext.object(with: settingObjectID) as! Setting
|
||||
setting.update(preferredTrueBlackDarkMode: isOn)
|
||||
}
|
||||
.sink { result in
|
||||
switch result {
|
||||
case .success:
|
||||
ThemeService.shared.set(themeName: isOn ? .system : .mastodon)
|
||||
case .failure(let error):
|
||||
assertionFailure(error.localizedDescription)
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
case .preferenceDisableAvatarAnimation(let settingObjectID):
|
||||
let managedObjectContext = context.backgroundManagedObjectContext
|
||||
managedObjectContext.performChanges {
|
||||
let setting = managedObjectContext.object(with: settingObjectID) as! Setting
|
||||
setting.update(preferredStaticAvatar: isOn)
|
||||
}
|
||||
.sink { result in
|
||||
switch result {
|
||||
case .success:
|
||||
UserDefaults.shared.preferredStaticAvatar = isOn
|
||||
case .failure(let error):
|
||||
assertionFailure(error.localizedDescription)
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
case .preferenceUsingDefaultBrowser(let settingObjectID):
|
||||
let managedObjectContext = context.backgroundManagedObjectContext
|
||||
managedObjectContext.performChanges {
|
||||
|
@ -537,6 +541,13 @@ extension SettingsViewController: ActiveLabelDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - ASAuthorizationControllerPresentationContextProviding
|
||||
extension SettingsViewController: ASWebAuthenticationPresentationContextProviding {
|
||||
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
|
||||
return view.window!
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIAdaptivePresentationControllerDelegate
|
||||
extension SettingsViewController: UIAdaptivePresentationControllerDelegate {
|
||||
func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle {
|
||||
|
|
|
@ -12,13 +12,15 @@ import Foundation
|
|||
import MastodonSDK
|
||||
import UIKit
|
||||
import os.log
|
||||
import AuthenticationServices
|
||||
|
||||
class SettingsViewModel {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
let context: AppContext
|
||||
|
||||
var mastodonAuthenticationController: MastodonAuthenticationController?
|
||||
|
||||
// input
|
||||
let setting: CurrentValueSubject<Setting, Never>
|
||||
var updateDisposeBag = Set<AnyCancellable>()
|
||||
|
@ -85,6 +87,20 @@ class SettingsViewModel {
|
|||
}
|
||||
|
||||
extension SettingsViewModel {
|
||||
|
||||
func openAuthenticationPage(
|
||||
authenticateURL: URL,
|
||||
presentationContextProvider: ASWebAuthenticationPresentationContextProviding
|
||||
) {
|
||||
let authenticationController = MastodonAuthenticationController(
|
||||
context: self.context,
|
||||
authenticateURL: authenticateURL
|
||||
)
|
||||
|
||||
self.mastodonAuthenticationController = authenticationController
|
||||
authenticationController.authenticationSession?.presentationContextProvider = presentationContextProvider
|
||||
authenticationController.authenticationSession?.start()
|
||||
}
|
||||
|
||||
// MARK: - Private methods
|
||||
private func processDataSource(_ setting: Setting) {
|
||||
|
@ -96,13 +112,6 @@ extension SettingsViewModel {
|
|||
snapshot.appendSections([.appearance])
|
||||
snapshot.appendItems(appearanceItems, toSection: .appearance)
|
||||
|
||||
let appearanceSettingItems = [
|
||||
SettingsItem.appearanceDarkMode(settingObjectID: setting.objectID),
|
||||
SettingsItem.appearanceDisableAvatarAnimation(settingObjectID: setting.objectID)
|
||||
]
|
||||
snapshot.appendSections([.appearanceSettings])
|
||||
snapshot.appendItems(appearanceSettingItems, toSection: .appearanceSettings)
|
||||
|
||||
// notification
|
||||
let notificationItems = SettingsItem.NotificationSwitchMode.allCases.map { mode in
|
||||
SettingsItem.notification(settingObjectID: setting.objectID, switchMode: mode)
|
||||
|
@ -112,11 +121,17 @@ extension SettingsViewModel {
|
|||
|
||||
// preference
|
||||
snapshot.appendSections([.preference])
|
||||
snapshot.appendItems([.preferenceUsingDefaultBrowser(settingObjectID: setting.objectID)], toSection: .preference)
|
||||
let preferenceItems: [SettingsItem] = [
|
||||
.preferenceDarkMode(settingObjectID: setting.objectID),
|
||||
.preferenceDisableAvatarAnimation(settingObjectID: setting.objectID),
|
||||
.preferenceUsingDefaultBrowser(settingObjectID: setting.objectID),
|
||||
]
|
||||
snapshot.appendItems(preferenceItems,toSection: .preference)
|
||||
|
||||
// boring zone
|
||||
let boringZoneSettingsItems: [SettingsItem] = {
|
||||
let links: [SettingsItem.Link] = [
|
||||
.accountSettings,
|
||||
.termsOfService,
|
||||
.privacyPolicy
|
||||
]
|
||||
|
@ -174,8 +189,8 @@ extension SettingsViewModel {
|
|||
}
|
||||
cell.delegate = settingsAppearanceTableViewCellDelegate
|
||||
return cell
|
||||
case .appearanceDarkMode(let objectID),
|
||||
.appearanceDisableAvatarAnimation(let objectID),
|
||||
case .preferenceDarkMode(let objectID),
|
||||
.preferenceDisableAvatarAnimation(let objectID),
|
||||
.preferenceUsingDefaultBrowser(let objectID):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsToggleTableViewCell.self), for: indexPath) as! SettingsToggleTableViewCell
|
||||
cell.delegate = settingsToggleCellDelegate
|
||||
|
@ -236,10 +251,10 @@ extension SettingsViewModel {
|
|||
setting: Setting
|
||||
) {
|
||||
switch item {
|
||||
case .appearanceDarkMode:
|
||||
case .preferenceDarkMode:
|
||||
cell.textLabel?.text = L10n.Scene.Settings.Section.AppearanceSettings.trueBlackDarkMode
|
||||
cell.switchButton.isOn = setting.preferredTrueBlackDarkMode
|
||||
case .appearanceDisableAvatarAnimation:
|
||||
case .preferenceDisableAvatarAnimation:
|
||||
cell.textLabel?.text = L10n.Scene.Settings.Section.AppearanceSettings.disableAvatarAnimation
|
||||
cell.switchButton.isOn = setting.preferredStaticAvatar
|
||||
case .preferenceUsingDefaultBrowser:
|
||||
|
|
|
@ -7,34 +7,10 @@
|
|||
|
||||
import UIKit
|
||||
|
||||
// Make status bar style adptive for child view controller
|
||||
// Make status bar style adaptive for child view controller
|
||||
// SeeAlso: `modalPresentationCapturesStatusBarAppearance`
|
||||
final class AdaptiveStatusBarStyleNavigationController: UINavigationController {
|
||||
var viewControllersHiddenNavigationBar: [UIViewController.Type]
|
||||
|
||||
override var childForStatusBarStyle: UIViewController? {
|
||||
visibleViewController
|
||||
}
|
||||
|
||||
override init(rootViewController: UIViewController) {
|
||||
self.viewControllersHiddenNavigationBar = [SearchViewController.self]
|
||||
super.init(rootViewController: rootViewController)
|
||||
self.delegate = self
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
extension AdaptiveStatusBarStyleNavigationController: UINavigationControllerDelegate {
|
||||
func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
|
||||
let isContain = self.viewControllersHiddenNavigationBar.contains { type(of: viewController) == $0 }
|
||||
if isContain {
|
||||
self.setNavigationBarHidden(true, animated: animated)
|
||||
} else {
|
||||
self.setNavigationBarHidden(false, animated: animated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,11 +9,18 @@ import UIKit
|
|||
import Combine
|
||||
|
||||
final class TimelineBottomLoaderTableViewCell: TimelineLoaderTableViewCell {
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
|
||||
loadMoreLabel.isHidden = true
|
||||
loadMoreButton.isHidden = true
|
||||
}
|
||||
|
||||
override func _init() {
|
||||
super._init()
|
||||
|
||||
activityIndicatorView.isHidden = false
|
||||
|
||||
startAnimating()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -84,6 +84,12 @@ extension ThreadViewController: StatusProvider {
|
|||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func statusObjectItems(indexPaths: [IndexPath]) -> [StatusObjectItem] {
|
||||
guard let diffableDataSource = self.viewModel.diffableDataSource else { return [] }
|
||||
let items = indexPaths.compactMap { diffableDataSource.itemIdentifier(for: $0)?.statusObjectItem }
|
||||
return items
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,81 @@
|
|||
//
|
||||
// SearchToSearchDetailViewControllerAnimatedTransitioning.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-7-13.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
|
||||
final class SearchToSearchDetailViewControllerAnimatedTransitioning: ViewControllerAnimatedTransitioning {
|
||||
|
||||
private var animator: UIViewPropertyAnimator?
|
||||
|
||||
override init(operation: UINavigationController.Operation) {
|
||||
super.init(operation: operation)
|
||||
|
||||
self.transitionDuration = 0.2
|
||||
}
|
||||
|
||||
deinit {
|
||||
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - UIViewControllerAnimatedTransitioning
|
||||
extension SearchToSearchDetailViewControllerAnimatedTransitioning {
|
||||
|
||||
override func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
super.animateTransition(using: transitionContext)
|
||||
|
||||
switch operation {
|
||||
case .push: pushTransition(using: transitionContext).startAnimation()
|
||||
case .pop: popTransition(using: transitionContext).startAnimation()
|
||||
default: return
|
||||
}
|
||||
}
|
||||
|
||||
private func pushTransition(using transitionContext: UIViewControllerContextTransitioning, curve: UIView.AnimationCurve = .easeOut) -> UIViewPropertyAnimator {
|
||||
guard let toVC = transitionContext.viewController(forKey: .to) as? SearchDetailViewController,
|
||||
let toView = transitionContext.view(forKey: .to) else {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
let toViewEndFrame = transitionContext.finalFrame(for: toVC)
|
||||
transitionContext.containerView.addSubview(toView)
|
||||
toView.frame = toViewEndFrame
|
||||
toView.alpha = 0
|
||||
|
||||
let animator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), curve: curve)
|
||||
animator.addAnimations {
|
||||
|
||||
}
|
||||
animator.addCompletion { position in
|
||||
toView.alpha = 1
|
||||
transitionContext.completeTransition(true)
|
||||
}
|
||||
return animator
|
||||
}
|
||||
|
||||
private func popTransition(using transitionContext: UIViewControllerContextTransitioning, curve: UIView.AnimationCurve = .easeInOut) -> UIViewPropertyAnimator {
|
||||
guard let toVC = transitionContext.viewController(forKey: .to) as? SearchViewController,
|
||||
let toView = transitionContext.view(forKey: .to) else {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
let toViewEndFrame = transitionContext.finalFrame(for: toVC)
|
||||
transitionContext.containerView.addSubview(toView)
|
||||
toView.frame = toViewEndFrame
|
||||
|
||||
let animator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), curve: curve)
|
||||
animator.addAnimations {
|
||||
|
||||
}
|
||||
animator.addCompletion { position in
|
||||
transitionContext.completeTransition(true)
|
||||
}
|
||||
return animator
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
//
|
||||
// SearchTransitionController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-7-13.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class SearchTransitionController: NSObject {
|
||||
|
||||
}
|
||||
|
||||
// MARK: - UINavigationControllerDelegate
|
||||
extension SearchTransitionController: UINavigationControllerDelegate {
|
||||
func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationController.Operation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||
switch operation {
|
||||
case .push where fromVC is SearchViewController && toVC is SearchDetailViewController:
|
||||
return SearchToSearchDetailViewControllerAnimatedTransitioning(operation: operation)
|
||||
case .pop where fromVC is SearchDetailViewController && toVC is SearchViewController:
|
||||
return SearchToSearchDetailViewControllerAnimatedTransitioning(operation: operation)
|
||||
default:
|
||||
// fix edge dismiss gesture
|
||||
toVC.navigationController?.interactivePopGestureRecognizer?.delegate = nil
|
||||
// assertionFailure("Wrong setup. Edge-drag gesture will be invalid. Set delegate to nil when using system push configuration")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
|
||||
switch viewController {
|
||||
case is SearchDetailViewController:
|
||||
navigationController.interactivePopGestureRecognizer?.isEnabled = false
|
||||
default:
|
||||
navigationController.interactivePopGestureRecognizer?.isEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
|
@ -17,6 +17,7 @@ class ViewControllerAnimatedTransitioning: NSObject {
|
|||
|
||||
let operation: UINavigationController.Operation
|
||||
|
||||
var transitionDuration: TimeInterval
|
||||
var transitionContext: UIViewControllerContextTransitioning!
|
||||
var isInteractive: Bool { return transitionContext.isInteractive }
|
||||
|
||||
|
@ -25,6 +26,7 @@ class ViewControllerAnimatedTransitioning: NSObject {
|
|||
init(operation: UINavigationController.Operation) {
|
||||
assert(operation != .none)
|
||||
self.operation = operation
|
||||
self.transitionDuration = 0.3
|
||||
super.init()
|
||||
}
|
||||
|
||||
|
@ -38,7 +40,7 @@ class ViewControllerAnimatedTransitioning: NSObject {
|
|||
extension ViewControllerAnimatedTransitioning: UIViewControllerAnimatedTransitioning {
|
||||
|
||||
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
|
||||
return 0.3
|
||||
return transitionDuration
|
||||
}
|
||||
|
||||
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
|
|
|
@ -10,8 +10,21 @@ import Combine
|
|||
import MastodonSDK
|
||||
|
||||
extension APIService {
|
||||
|
||||
|
||||
func uploadMedia(
|
||||
domain: String,
|
||||
query: Mastodon.API.Media.UploadMediaQuery,
|
||||
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox,
|
||||
needsFallback: Bool
|
||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Attachment>, Error> {
|
||||
if needsFallback {
|
||||
return uploadMediaV1(domain: domain, query: query, mastodonAuthenticationBox: mastodonAuthenticationBox)
|
||||
} else {
|
||||
return uploadMediaV2(domain: domain, query: query, mastodonAuthenticationBox: mastodonAuthenticationBox)
|
||||
}
|
||||
}
|
||||
|
||||
private func uploadMediaV1(
|
||||
domain: String,
|
||||
query: Mastodon.API.Media.UploadMediaQuery,
|
||||
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
|
||||
|
@ -25,6 +38,22 @@ extension APIService {
|
|||
authorization: authorization
|
||||
)
|
||||
}
|
||||
|
||||
private func uploadMediaV2(
|
||||
domain: String,
|
||||
query: Mastodon.API.Media.UploadMediaQuery,
|
||||
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
|
||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Attachment>, Error> {
|
||||
let authorization = mastodonAuthenticationBox.userAuthorization
|
||||
|
||||
return Mastodon.API.V2.Media.uploadMedia(
|
||||
session: session,
|
||||
domain: domain,
|
||||
query: query,
|
||||
authorization: authorization
|
||||
)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func updateMedia(
|
||||
domain: String,
|
||||
|
|
|
@ -6,8 +6,9 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import MastodonSDK
|
||||
import Combine
|
||||
import MastodonSDK
|
||||
import CommonOSLog
|
||||
|
||||
extension APIService {
|
||||
|
||||
|
@ -17,7 +18,32 @@ extension APIService {
|
|||
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
|
||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> {
|
||||
let authorization = mastodonAuthenticationBox.userAuthorization
|
||||
let requestMastodonUserID = mastodonAuthenticationBox.userID
|
||||
|
||||
return Mastodon.API.V2.Search.search(session: session, domain: domain, query: query, authorization: authorization)
|
||||
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> in
|
||||
// persist status
|
||||
let statusResponse = response.map { $0.statuses }
|
||||
return APIService.Persist.persistStatus(
|
||||
managedObjectContext: self.backgroundManagedObjectContext,
|
||||
domain: domain,
|
||||
query: nil,
|
||||
response: statusResponse,
|
||||
persistType: .lookUp,
|
||||
requestMastodonUserID: requestMastodonUserID,
|
||||
log: OSLog.api
|
||||
)
|
||||
.setFailureType(to: Error.self)
|
||||
.tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.SearchResult> in
|
||||
switch result {
|
||||
case .success:
|
||||
return response
|
||||
case .failure(let error):
|
||||
throw error
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -102,7 +102,7 @@ extension APIService.CoreData {
|
|||
let metaData = attachment.meta.flatMap { meta in
|
||||
try? encoder.encode(meta)
|
||||
}
|
||||
let property = Attachment.Property(domain: domain, index: index, id: attachment.id, typeRaw: attachment.type.rawValue, url: attachment.url, previewURL: attachment.previewURL, remoteURL: attachment.remoteURL, metaData: metaData, textURL: attachment.textURL, descriptionString: attachment.description, blurhash: attachment.blurhash, networkDate: networkDate)
|
||||
let property = Attachment.Property(domain: domain, index: index, id: attachment.id, typeRaw: attachment.type.rawValue, url: attachment.url ?? "", previewURL: attachment.previewURL, remoteURL: attachment.remoteURL, metaData: metaData, textURL: attachment.textURL, descriptionString: attachment.description, blurhash: attachment.blurhash, networkDate: networkDate)
|
||||
attachments.append(Attachment.insert(into: managedObjectContext, property: property))
|
||||
}
|
||||
guard !attachments.isEmpty else { return nil }
|
||||
|
|
|
@ -27,7 +27,6 @@ final class AuthenticationService: NSObject {
|
|||
let mastodonAuthenticationBoxes = CurrentValueSubject<[AuthenticationService.MastodonAuthenticationBox], Never>([])
|
||||
let activeMastodonAuthentication = CurrentValueSubject<MastodonAuthentication?, Never>(nil)
|
||||
let activeMastodonAuthenticationBox = CurrentValueSubject<AuthenticationService.MastodonAuthenticationBox?, Never>(nil)
|
||||
let activeFilters = CurrentValueSubject<[Mastodon.Entity.Filter], Never>([])
|
||||
|
||||
init(
|
||||
managedObjectContext: NSManagedObjectContext,
|
||||
|
@ -88,53 +87,6 @@ final class AuthenticationService: NSObject {
|
|||
} catch {
|
||||
assertionFailure(error.localizedDescription)
|
||||
}
|
||||
|
||||
// fetch account filters every 60s and filter out expired items
|
||||
let filterUpdateTimerPublisher = Timer.publish(every: 60.0, on: .main, in: .common)
|
||||
.autoconnect()
|
||||
.share()
|
||||
.eraseToAnyPublisher()
|
||||
let filterUpdatePublisher = PassthroughSubject<Void, Never>()
|
||||
|
||||
filterUpdateTimerPublisher
|
||||
.map { _ in }
|
||||
.subscribe(filterUpdatePublisher)
|
||||
.store(in: &disposeBag)
|
||||
|
||||
Publishers.CombineLatest(
|
||||
activeMastodonAuthenticationBox,
|
||||
filterUpdatePublisher
|
||||
)
|
||||
.flatMap { box, _ -> AnyPublisher<Result<Mastodon.Response.Content<[Mastodon.Entity.Filter]>, Error>, Never> in
|
||||
guard let box = box else {
|
||||
return Just(Result { throw APIService.APIError.implicit(.authenticationMissing) }).eraseToAnyPublisher()
|
||||
}
|
||||
return apiService.filters(mastodonAuthenticationBox: box)
|
||||
.map { response in
|
||||
let now = Date()
|
||||
let newResponse = response.map { filters in
|
||||
return filters.filter { $0.expiresAt > now }
|
||||
}
|
||||
return Result<Mastodon.Response.Content<[Mastodon.Entity.Filter]>, Error>.success(newResponse)
|
||||
}
|
||||
.catch { error in
|
||||
Just(Result<Mastodon.Response.Content<[Mastodon.Entity.Filter]>, Error>.failure(error))
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.sink { result in
|
||||
switch result {
|
||||
case .success(let response):
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch account filters success. %ld items", ((#file as NSString).lastPathComponent), #line, #function, response.value.count)
|
||||
self.activeFilters.value = response.value
|
||||
case .failure(let error):
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch account filters fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
filterUpdatePublisher.send()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import os.log
|
||||
import Foundation
|
||||
import Combine
|
||||
import GameplayKit
|
||||
import MastodonSDK
|
||||
|
||||
|
@ -43,8 +44,10 @@ extension MastodonAttachmentService.UploadState {
|
|||
}
|
||||
|
||||
class Uploading: MastodonAttachmentService.UploadState {
|
||||
var needsFallback = false
|
||||
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
return stateClass == Fail.self || stateClass == Finish.self
|
||||
return stateClass == Fail.self || stateClass == Finish.self || stateClass == Uploading.self
|
||||
}
|
||||
|
||||
override func didEnter(from previousState: GKState?) {
|
||||
|
@ -61,30 +64,43 @@ extension MastodonAttachmentService.UploadState {
|
|||
description: description,
|
||||
focus: nil
|
||||
)
|
||||
|
||||
|
||||
// and needs clone the `query` if needs retry
|
||||
service.context.apiService.uploadMedia(
|
||||
domain: authenticationBox.domain,
|
||||
query: query,
|
||||
mastodonAuthenticationBox: authenticationBox
|
||||
mastodonAuthenticationBox: authenticationBox,
|
||||
needsFallback: needsFallback
|
||||
)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { completion in
|
||||
.sink { [weak self] completion in
|
||||
guard let self = self else { return }
|
||||
switch completion {
|
||||
case .failure(let error):
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: upload attachment fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
||||
service.error.send(error)
|
||||
stateMachine.enter(Fail.self)
|
||||
if let apiError = error as? Mastodon.API.Error,
|
||||
apiError.httpResponseStatus == .notFound,
|
||||
self.needsFallback == false
|
||||
{
|
||||
self.needsFallback = true
|
||||
stateMachine.enter(Uploading.self)
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: upload attachment fallback to V1", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
||||
} else {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: upload attachment fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
||||
service.error.send(error)
|
||||
stateMachine.enter(Fail.self)
|
||||
}
|
||||
case .finished:
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: upload attachment success", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
break
|
||||
}
|
||||
} receiveValue: { response in
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: upload attachment %s success: %s", ((#file as NSString).lastPathComponent), #line, #function, response.value.id, response.value.url)
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: upload attachment %s success: %s", ((#file as NSString).lastPathComponent), #line, #function, response.value.id, response.value.url ?? "<nil>")
|
||||
service.attachment.value = response.value
|
||||
stateMachine.enter(Finish.self)
|
||||
}
|
||||
.store(in: &service.disposeBag)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class Fail: MastodonAttachmentService.UploadState {
|
||||
|
|
|
@ -0,0 +1,86 @@
|
|||
//
|
||||
// StatusFilterService.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by Cirno MainasuK on 2021-7-14.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import Foundation
|
||||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
import MastodonMeta
|
||||
|
||||
final class StatusFilterService {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
// input
|
||||
weak var apiService: APIService?
|
||||
weak var authenticationService: AuthenticationService?
|
||||
let filterUpdatePublisher = PassthroughSubject<Void, Never>()
|
||||
|
||||
// output
|
||||
let activeFilters = CurrentValueSubject<[Mastodon.Entity.Filter], Never>([])
|
||||
|
||||
init(
|
||||
apiService: APIService,
|
||||
authenticationService: AuthenticationService
|
||||
) {
|
||||
self.apiService = apiService
|
||||
self.authenticationService = authenticationService
|
||||
|
||||
// fetch account filters every 300s
|
||||
// also trigger fetch when app resume from background
|
||||
let filterUpdateTimerPublisher = Timer.publish(every: 300.0, on: .main, in: .common)
|
||||
.autoconnect()
|
||||
.share()
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
filterUpdateTimerPublisher
|
||||
.map { _ in }
|
||||
.subscribe(filterUpdatePublisher)
|
||||
.store(in: &disposeBag)
|
||||
|
||||
let activeMastodonAuthenticationBox = authenticationService.activeMastodonAuthenticationBox
|
||||
Publishers.CombineLatest(
|
||||
activeMastodonAuthenticationBox,
|
||||
filterUpdatePublisher
|
||||
)
|
||||
.flatMap { box, _ -> AnyPublisher<Result<Mastodon.Response.Content<[Mastodon.Entity.Filter]>, Error>, Never> in
|
||||
guard let box = box else {
|
||||
return Just(Result { throw APIService.APIError.implicit(.authenticationMissing) }).eraseToAnyPublisher()
|
||||
}
|
||||
return apiService.filters(mastodonAuthenticationBox: box)
|
||||
.map { response in
|
||||
let now = Date()
|
||||
let newResponse = response.map { filters in
|
||||
return filters.filter { $0.expiresAt > now } // filter out expired rules
|
||||
}
|
||||
return Result<Mastodon.Response.Content<[Mastodon.Entity.Filter]>, Error>.success(newResponse)
|
||||
}
|
||||
.catch { error in
|
||||
Just(Result<Mastodon.Response.Content<[Mastodon.Entity.Filter]>, Error>.failure(error))
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.sink { result in
|
||||
switch result {
|
||||
case .success(let response):
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch account filters success. %ld items", ((#file as NSString).lastPathComponent), #line, #function, response.value.count)
|
||||
self.activeFilters.value = response.value
|
||||
case .failure(let error):
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch account filters fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
// make initial trigger once
|
||||
filterUpdatePublisher.send()
|
||||
}
|
||||
|
||||
}
|
|
@ -11,24 +11,92 @@ import Combine
|
|||
import CoreData
|
||||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
import MastodonMeta
|
||||
|
||||
final class StatusPrefetchingService {
|
||||
|
||||
typealias TaskID = String
|
||||
typealias StatusObjectID = NSManagedObjectID
|
||||
|
||||
let workingQueue = DispatchQueue(label: "org.joinmastodon.app.StatusPrefetchingService.working-queue")
|
||||
|
||||
// StatusContentOperation
|
||||
let statusContentOperationQueue: OperationQueue = {
|
||||
let queue = OperationQueue()
|
||||
queue.name = "org.joinmastodon.app.StatusPrefetchingService.statusContentOperationQueue"
|
||||
queue.maxConcurrentOperationCount = 2
|
||||
return queue
|
||||
}()
|
||||
var statusContentOperations: [StatusObjectID: StatusContentOperation] = [:]
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
private(set) var statusPrefetchingDisposeBagDict: [TaskID: AnyCancellable] = [:]
|
||||
|
||||
|
||||
// input
|
||||
weak var apiService: APIService?
|
||||
let managedObjectContext: NSManagedObjectContext
|
||||
let backgroundManagedObjectContext: NSManagedObjectContext // read-only
|
||||
|
||||
init(apiService: APIService) {
|
||||
init(
|
||||
managedObjectContext: NSManagedObjectContext,
|
||||
backgroundManagedObjectContext: NSManagedObjectContext,
|
||||
apiService: APIService
|
||||
) {
|
||||
self.managedObjectContext = managedObjectContext
|
||||
self.backgroundManagedObjectContext = backgroundManagedObjectContext
|
||||
self.apiService = apiService
|
||||
}
|
||||
|
||||
private func status(from statusObjectItem: StatusObjectItem) -> Status? {
|
||||
assert(Thread.isMainThread)
|
||||
switch statusObjectItem {
|
||||
case .homeTimelineIndex(let objectID):
|
||||
let homeTimelineIndex = try? managedObjectContext.existingObject(with: objectID) as? HomeTimelineIndex
|
||||
return homeTimelineIndex?.status
|
||||
case .mastodonNotification(let objectID):
|
||||
let mastodonNotification = try? managedObjectContext.existingObject(with: objectID) as? MastodonNotification
|
||||
return mastodonNotification?.status
|
||||
case .status(let objectID):
|
||||
let status = try? managedObjectContext.existingObject(with: objectID) as? Status
|
||||
return status
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension StatusPrefetchingService {
|
||||
func prefetch(statusObjectItems items: [StatusObjectItem]) {
|
||||
for item in items {
|
||||
guard let status = status(from: item), !status.isDeleted else { continue }
|
||||
|
||||
// status content parser task
|
||||
if statusContentOperations[status.objectID] == nil {
|
||||
let mastodonContent = MastodonContent(
|
||||
content: (status.reblog ?? status).content,
|
||||
emojis: (status.reblog ?? status).emojiMeta
|
||||
)
|
||||
let operation = StatusContentOperation(
|
||||
statusObjectID: status.objectID,
|
||||
mastodonContent: mastodonContent
|
||||
)
|
||||
statusContentOperations[status.objectID] = operation
|
||||
statusContentOperationQueue.addOperation(operation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cancelPrefetch(statusObjectItems items: [StatusObjectItem]) {
|
||||
for item in items {
|
||||
guard let status = status(from: item), !status.isDeleted else { continue }
|
||||
|
||||
// cancel status content parser task
|
||||
statusContentOperations.removeValue(forKey: status.objectID)?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension StatusPrefetchingService {
|
||||
|
||||
func prefetchReplyTo(
|
||||
|
|
|
@ -33,8 +33,9 @@ class AppContext: ObservableObject {
|
|||
let settingService: SettingService
|
||||
|
||||
let blockDomainService: BlockDomainService
|
||||
let statusFilterService: StatusFilterService
|
||||
let photoLibraryService = PhotoLibraryService()
|
||||
|
||||
|
||||
let placeholderImageCacheService = PlaceholderImageCacheService()
|
||||
let blurhashImageCacheService = BlurhashImageCacheService()
|
||||
let statusContentCacheService = StatusContentCacheService()
|
||||
|
@ -69,7 +70,10 @@ class AppContext: ObservableObject {
|
|||
emojiService = EmojiService(
|
||||
apiService: apiService
|
||||
)
|
||||
|
||||
statusPrefetchingService = StatusPrefetchingService(
|
||||
managedObjectContext: _managedObjectContext,
|
||||
backgroundManagedObjectContext: _backgroundManagedObjectContext,
|
||||
apiService: _apiService
|
||||
)
|
||||
let _notificationService = NotificationService(
|
||||
|
@ -88,6 +92,11 @@ class AppContext: ObservableObject {
|
|||
backgroundManagedObjectContext: _backgroundManagedObjectContext,
|
||||
authenticationService: _authenticationService
|
||||
)
|
||||
|
||||
statusFilterService = StatusFilterService(
|
||||
apiService: _apiService,
|
||||
authenticationService: _authenticationService
|
||||
)
|
||||
|
||||
documentStore = DocumentStore()
|
||||
documentStoreSubscription = documentStore.objectWillChange
|
||||
|
|
|
@ -81,6 +81,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||
// reset notification badge
|
||||
UserDefaults.shared.notificationBadgeCount = 0
|
||||
UIApplication.shared.applicationIconBadgeNumber = 0
|
||||
|
||||
// trigger status filter update
|
||||
AppContext.shared.statusFilterService.filterUpdatePublisher.send()
|
||||
}
|
||||
|
||||
func sceneWillResignActive(_ scene: UIScene) {
|
||||
|
|
|
@ -104,7 +104,12 @@ extension Mastodon.API.Media {
|
|||
|
||||
return SerialStream(streams: streams)
|
||||
}
|
||||
|
||||
public var clone: UploadMediaQuery {
|
||||
UploadMediaQuery(file: file, thumbnail: thumbnail, description: description, focus: focus)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,57 @@
|
|||
//
|
||||
// Mastodon+API+V2+Media.swift
|
||||
//
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-7-15.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
extension Mastodon.API.V2.Media {
|
||||
static func uploadMediaEndpointURL(domain: String) -> URL {
|
||||
Mastodon.API.endpointV2URL(domain: domain).appendingPathComponent("media")
|
||||
}
|
||||
|
||||
/// Upload media as attachment
|
||||
///
|
||||
/// Creates an attachment to be used with a new status.
|
||||
///
|
||||
/// - Since: 0.0.0
|
||||
/// - Version: 3.4.1
|
||||
/// # Last Update
|
||||
/// 2021/7/15
|
||||
/// # Reference
|
||||
/// [Document](https://docs.joinmastodon.org/methods/statuses/media/)
|
||||
/// - Parameters:
|
||||
/// - session: `URLSession`
|
||||
/// - domain: Mastodon instance domain. e.g. "example.com"
|
||||
/// - query: `UploadMediaQuery`
|
||||
/// - authorization: User token
|
||||
/// - Returns: `AnyPublisher` contains `Attachment` nested in the response
|
||||
public static func uploadMedia(
|
||||
session: URLSession,
|
||||
domain: String,
|
||||
query: Mastodon.API.Media.UploadMediaQuery,
|
||||
authorization: Mastodon.API.OAuth.Authorization?
|
||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Attachment>, Error> {
|
||||
var request = Mastodon.API.post(
|
||||
url: uploadMediaEndpointURL(domain: domain),
|
||||
query: query,
|
||||
authorization: authorization
|
||||
)
|
||||
request.timeoutInterval = 180 // should > 200 Kb/s for 40 MiB media attachment
|
||||
let serialStream = query.serialStream
|
||||
request.httpBodyStream = serialStream.boundStreams.input
|
||||
return session.dataTaskPublisher(for: request)
|
||||
.tryMap { data, response in
|
||||
let value = try Mastodon.API.decode(type: Mastodon.Entity.Attachment.self, from: data, response: response)
|
||||
return Mastodon.Response.Content(value: value, response: response)
|
||||
}
|
||||
.handleEvents(receiveCancel: {
|
||||
// retain and handle cancel task
|
||||
serialStream.boundStreams.output.close()
|
||||
})
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
|
@ -105,8 +105,8 @@ extension Mastodon.API.V2.Search {
|
|||
}
|
||||
}
|
||||
|
||||
public extension Mastodon.API.V2.Search {
|
||||
enum SearchType: String, Codable {
|
||||
extension Mastodon.API.V2.Search {
|
||||
public enum SearchType: String, Codable {
|
||||
case accounts
|
||||
case hashtags
|
||||
case statuses
|
||||
|
|
|
@ -123,6 +123,7 @@ extension Mastodon.API {
|
|||
extension Mastodon.API.V2 {
|
||||
public enum Search { }
|
||||
public enum Suggestions { }
|
||||
public enum Media { }
|
||||
}
|
||||
|
||||
extension Mastodon.API {
|
||||
|
|
|
@ -22,8 +22,8 @@ extension Mastodon.Entity {
|
|||
|
||||
public let id: ID
|
||||
public let type: Type
|
||||
public let url: String
|
||||
public let previewURL: String? // could be nil when attachement is audio
|
||||
public let url: String? // media v2 may return null url
|
||||
public let previewURL: String? // could be nil when attachment is audio
|
||||
|
||||
public let remoteURL: String?
|
||||
public let textURL: String?
|
||||
|
|
Loading…
Reference in New Issue