Merge pull request #215 from mastodon/fix/search

Add posts search supports
This commit is contained in:
CMK 2021-07-15 21:07:08 +08:00 committed by GitHub
commit 58e9f1b5d0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
93 changed files with 3430 additions and 1540 deletions

View File

@ -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>

View File

@ -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>?

View File

@ -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 {

View File

@ -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>?

View File

@ -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>?

View File

@ -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 cant view this profile\n until you unblock them.\nYour account looks like this to them.",
"user_blocking_warning": "You cant view %ss profile\n until you unblock them.\nYour account looks like this to them.",
"blocked_warning": "You cant view thiss profile\n until they unblock you.",
"no_status_found": "No Post Found",
"blocking_warning": "You cant view this user's profile\n until you unblock them.\nYour profile looks like this to them.",
"user_blocking_warning": "You cant view %ss profile\n until you unblock them.\nYour profile looks like this to them.",
"blocked_warning": "You cant view this users profile\n until they unblock you.",
"user_blocked_warning": "You cant view %ss profile\n until they unblock you.",
"suspended_warning": "This account has been suspended.",
"suspended_warning": "This user has been suspended.",
"user_suspended_warning": "%ss 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 (cant 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 cant be\nuploaded to Mastodon.",
"description_photo": "Describe photo for low vision people...",
"description_video": "Describe whats 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"
}

View File

@ -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 = 38;
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.8.12;
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 = 38;
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.8.12;
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 = 38;
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.8.12;
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 = 38;
DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = NotificationService/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
@ -4387,7 +4518,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 0.8.11;
MARKETING_VERSION = 0.8.12;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@ -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 = 38;
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.8.12;
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 = 38;
DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = NotificationService/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
@ -4620,7 +4751,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 0.8.11;
MARKETING_VERSION = 0.8.12;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@ -4666,7 +4797,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 37;
CURRENT_PROJECT_VERSION = 38;
DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = NotificationService/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
@ -4674,7 +4805,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 0.8.11;
MARKETING_VERSION = 0.8.12;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@ -4689,7 +4820,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 37;
CURRENT_PROJECT_VERSION = 38;
DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = NotificationService/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
@ -4697,7 +4828,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 0.8.11;
MARKETING_VERSION = 0.8.12;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;

View File

@ -12,7 +12,7 @@
<key>CoreDataStack.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>20</integer>
<integer>28</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>27</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>

View File

@ -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

View File

@ -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 }
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}
}

View File

@ -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)
}
}
}

View File

@ -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
}
}
}

View File

@ -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

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}
}
}
}

View File

@ -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
}
}
}

View File

@ -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()
}
}

View File

@ -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 cant view thiss profile\n until they unblock you.
/// You cant view this users profile\n until they unblock you.
internal static let blockedWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.BlockedWarning")
/// You cant view this profile\n until you unblock them.\nYour account looks like this to them.
/// You cant 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 cant 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 cant view %@s profile\n until you unblock them.\nYour account looks like this to them.
/// You cant 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 whats 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")
}
}
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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)

View File

@ -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:)

View File

@ -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 cant view thiss profile
"Common.Controls.Timeline.Header.BlockedWarning" = "You cant view this users profile
until they unblock you.";
"Common.Controls.Timeline.Header.BlockingWarning" = "You cant view this profile
"Common.Controls.Timeline.Header.BlockingWarning" = "You cant 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 cant view %@s profile
until they unblock you.";
"Common.Controls.Timeline.Header.UserBlockingWarning" = "You cant 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 cant be
uploaded to Mastodon.";
"Scene.Compose.Attachment.DescriptionPhoto" = "Describe photo for low vision people...";
"Scene.Compose.Attachment.DescriptionVideo" = "Describe whats 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 whats 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 (cant 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, youll see their posts in your home feed.";
"Scene.SuggestionAccount.Title" = "Find People to Follow";

View File

@ -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 cant view thiss profile
"Common.Controls.Timeline.Header.BlockedWarning" = "You cant view this users profile
until they unblock you.";
"Common.Controls.Timeline.Header.BlockingWarning" = "You cant view this profile
"Common.Controls.Timeline.Header.BlockingWarning" = "You cant 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 cant view %@s profile
until they unblock you.";
"Common.Controls.Timeline.Header.UserBlockingWarning" = "You cant 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 cant be
uploaded to Mastodon.";
"Scene.Compose.Attachment.DescriptionPhoto" = "Describe photo for low vision people...";
"Scene.Compose.Attachment.DescriptionVideo" = "Describe whats 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 whats 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 (cant 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, youll see their posts in your home feed.";
"Scene.SuggestionAccount.Title" = "Find People to Follow";

View File

@ -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
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}
}

View File

@ -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

View File

@ -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
}
}

View File

@ -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
}

View File

@ -0,0 +1,198 @@
//
// 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
}()
}
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
}
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)
// 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)
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

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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
}
}
}
}

View File

@ -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()
}
}

View File

@ -0,0 +1,132 @@
//
// 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 {
items.append(.account(objectID: account.objectID))
} else if let hashtag = searchHistory.hashtag {
items.append(.hashtag(objectID: hashtag.objectID))
} 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)
}
}

View File

@ -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
}
}

View File

@ -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 {}

View File

@ -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)
}
}

View File

@ -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
}
}
}
}

View File

@ -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
}
}
}

View File

@ -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"

View File

@ -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)
}
}

View File

@ -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

View File

@ -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)
}
}
}
}

View File

@ -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
}
}
}

View File

@ -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 {

View File

@ -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:

View File

@ -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)
}
}
}

View File

@ -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()
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}
}

View File

@ -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) {

View File

@ -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,

View File

@ -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()
}
}

View File

@ -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 }

View File

@ -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()
}
}

View File

@ -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 {

View File

@ -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()
}
}

View File

@ -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(

View File

@ -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

View File

@ -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) {

View File

@ -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)
}
}
}

View File

@ -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()
}
}

View File

@ -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

View File

@ -123,6 +123,7 @@ extension Mastodon.API {
extension Mastodon.API.V2 {
public enum Search { }
public enum Suggestions { }
public enum Media { }
}
extension Mastodon.API {

View File

@ -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?