diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents
index 98ed7f356..5bc61b648 100644
--- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents
+++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents
@@ -136,6 +136,7 @@
+
@@ -182,8 +183,9 @@
-
-
+
+
+
@@ -234,6 +236,7 @@
+
@@ -266,6 +269,7 @@
+
@@ -277,16 +281,16 @@
-
+
-
+
-
+
-
+
\ No newline at end of file
diff --git a/CoreDataStack/Entity/MastodonUser.swift b/CoreDataStack/Entity/MastodonUser.swift
index d442ec753..6b27b4cd8 100644
--- a/CoreDataStack/Entity/MastodonUser.swift
+++ b/CoreDataStack/Entity/MastodonUser.swift
@@ -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?
diff --git a/CoreDataStack/Entity/SearchHistory.swift b/CoreDataStack/Entity/SearchHistory.swift
index d924917ee..da6d98bc2 100644
--- a/CoreDataStack/Entity/SearchHistory.swift
+++ b/CoreDataStack/Entity/SearchHistory.swift
@@ -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 {
diff --git a/CoreDataStack/Entity/Status.swift b/CoreDataStack/Entity/Status.swift
index 14f687241..717e54ab7 100644
--- a/CoreDataStack/Entity/Status.swift
+++ b/CoreDataStack/Entity/Status.swift
@@ -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?
@NSManaged public private(set) var rebloggedBy: Set?
@NSManaged public private(set) var mutedBy: Set?
@NSManaged public private(set) var bookmarkedBy: Set?
- // 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?
diff --git a/CoreDataStack/Entity/Tag.swift b/CoreDataStack/Entity/Tag.swift
index 3044cacc0..6aeee520e 100644
--- a/CoreDataStack/Entity/Tag.swift
+++ b/CoreDataStack/Entity/Tag.swift
@@ -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?
diff --git a/Localization/app.json b/Localization/app.json
index ffa074ec3..6275742be 100644
--- a/Localization/app.json
+++ b/Localization/app.json
@@ -16,14 +16,14 @@
"poll_expired": "The poll has expired"
},
"discard_post_content": {
- "title": "Discard Publish",
- "message": "Confirm discard composed post content."
+ "title": "Discard Draft",
+ "message": "Confirm to discard composed post content."
},
"publish_post_failure": {
"title": "Publish Failure",
"message": "Failed to publish the post.\nPlease check your internet connection.",
"attchments_message": {
- "video_attach_with_photo": "Cannot attach a video to a status that already contains images.",
+ "video_attach_with_photo": "Cannot attach a video to a post that already contains images.",
"more_than_one_video": "Cannot attach more than one video."
}
},
@@ -32,17 +32,17 @@
"message": "Cannot edit profile. Please try again."
},
"sign_out": {
- "title": "Sign out",
+ "title": "Sign Out",
"message": "Are you sure you want to sign out?",
"confirm": "Sign Out"
},
"block_domain": {
- "title": "Are you really, really sure you want to block the entire %s? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.",
- "block_entire_domain": "Block entire domain"
+ "title": "Are you really, really sure you want to block the entire %s? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain and any of your followers from that domain will be removed.",
+ "block_entire_domain": "Block Domain"
},
"save_photo_failure": {
"title": "Save Photo Failure",
- "message": "Please enable photo libaray access permission to save photo."
+ "message": "Please enable the photo library access permission to save the photo."
},
"delete_post": {
"title": "Are you sure you want to delete this post?",
@@ -50,7 +50,7 @@
},
"clean_cache": {
"title": "Clean Cache",
- "message": "Successfully clean %s cache."
+ "message": "Successfully cleaned %s cache."
}
},
"controls": {
@@ -105,14 +105,14 @@
"open_settings": "Open Settings"
},
"timeline": {
- "previous_status": "Previous Status",
- "next_status": "Next Status",
- "open_status": "Open Status",
- "open_author_profile": "Open Author Profile",
- "open_reblogger_profile": "Open Reblogger Profile",
- "reply_status": "Reply Status",
- "toggle_reblog": "Toggle Status Reblog",
- "toggle_favorite": "Toggle Status Favorite",
+ "previous_status": "Previous Post",
+ "next_status": "Next Post",
+ "open_status": "Open Post",
+ "open_author_profile": "Open Author's Profile",
+ "open_reblogger_profile": "Open Reblogger's Profile",
+ "reply_status": "Reply to Post",
+ "toggle_reblog": "Toggle Reblog on Post",
+ "toggle_favorite": "Toggle Favorite on Post",
"toggle_content_warning": "Toggle Content Warning",
"preview_image": "Preview Image"
},
@@ -136,7 +136,7 @@
"actions": {
"reply": "Reply",
"reblog": "Reblog",
- "unreblog": "Unreblog",
+ "unreblog": "Undo reblog",
"favorite": "Favorite",
"unfavorite": "Unfavorite",
"menu": "Menu"
@@ -180,12 +180,12 @@
"show_more_replies": "Show more replies"
},
"header": {
- "no_status_found": "No Status Found",
- "blocking_warning": "You can’t view this profile\n until you unblock them.\nYour account looks like this to them.",
- "user_blocking_warning": "You can’t view %s’s profile\n until you unblock them.\nYour account looks like this to them.",
- "blocked_warning": "You can’t view this’s profile\n until they unblock you.",
+ "no_status_found": "No Post Found",
+ "blocking_warning": "You can’t view this user's profile\n until you unblock them.\nYour profile looks like this to them.",
+ "user_blocking_warning": "You can’t view %s’s profile\n until you unblock them.\nYour profile looks like this to them.",
+ "blocked_warning": "You can’t view this user’s profile\n until they unblock you.",
"user_blocked_warning": "You can’t view %s’s profile\n until they unblock you.",
- "suspended_warning": "This account has been suspended.",
+ "suspended_warning": "This user has been suspended.",
"user_suspended_warning": "%s’s account has been suspended."
},
"accessibility": {
@@ -232,7 +232,7 @@
},
"empty_state": {
"finding_servers": "Finding available servers...",
- "bad_network": "Something went wrong while loading data. Check your internet connection.",
+ "bad_network": "Something went wrong while loading the data. Check your internet connection.",
"no_results": "No results"
}
},
@@ -270,7 +270,7 @@
"reason": "Reason"
},
"reason": {
- "blocked": "%s contains a disallowed e-mail provider",
+ "blocked": "%s contains a disallowed email provider",
"unreachable": "%s does not seem to exist",
"taken": "%s is already in use",
"reserved": "%s is a reserved keyword",
@@ -284,7 +284,7 @@
"special": {
"username_invalid": "Username must only contain alphanumeric characters and underscores",
"username_too_long": "Username is too long (can’t be longer than 30 characters)",
- "email_invalid": "This is not a valid e-mail address",
+ "email_invalid": "This is not a valid email address",
"password_too_short": "Password is too short (must be at least 8 characters)"
}
}
@@ -351,8 +351,8 @@
"photo": "photo",
"video": "video",
"attachment_broken": "This %s is broken and can’t be\nuploaded to Mastodon.",
- "description_photo": "Describe photo for low vision people...",
- "description_video": "Describe what’s happening for low vision people..."
+ "description_photo": "Describe the photo for the visually-impaired...",
+ "description_video": "Describe the video for the visually-impaired..."
},
"poll": {
"duration_time": "Duration: %s",
@@ -377,13 +377,13 @@
"space_to_add": "Space to add"
},
"accessibility": {
- "append_attachment": "Append attachment",
- "append_poll": "Append poll",
- "remove_poll": "Remove poll",
- "custom_emoji_picker": "Custom emoji picker",
- "enable_content_warning": "Enable content warning",
- "disable_content_warning": "Disable content warning",
- "post_visibility_menu": "Post visibility menu",
+ "append_attachment": "Add Attachment",
+ "append_poll": "Add Poll",
+ "remove_poll": "Remove Poll",
+ "custom_emoji_picker": "Custom Emoji Picker",
+ "enable_content_warning": "Enable Content Warning",
+ "disable_content_warning": "Disable Content Warning",
+ "post_visibility_menu": "Post Visibility Menu",
"input_limit_remains_count": "Input limit remains %ld",
"input_limit_exceeds_count": "Input limit exceeds %ld"
},
@@ -392,7 +392,7 @@
"publish_post": "Publish Post",
"toggle_poll": "Toggle Poll",
"toggle_content_warning": "Toggle Content Warning",
- "append_attachment_entry": "Append Attachment - %s",
+ "append_attachment_entry": "Add Attachment - %s",
"select_visibility_entry": "Select Visibility - %s"
}
},
@@ -422,24 +422,25 @@
"relationship_action_alert": {
"confirm_unmute_user": {
"title": "Unmute Account",
- "message": "Confirm unmute %s"
+ "message": "Confirm to unmute %s"
},
"confirm_unblock_usre": {
"title": "Unblock Account",
- "message": "Confirm unblock %s"
+ "message": "Confirm to unblock %s"
}
}
},
"search": {
- "searchBar": {
+ "title": "Search",
+ "search_bar": {
"placeholder": "Search hashtags and users",
"cancel": "Cancel"
},
"recommend": {
"button_text": "See All",
"hash_tag": {
- "title": "Trending in your timeline",
- "description": "Hashtags that are getting quite a bit of attention among people you follow",
+ "title": "Trending on Mastodon",
+ "description": "Hashtags that are getting quite a bit of attention",
"people_talking": "%s people are talking"
},
"accounts": {
@@ -452,7 +453,11 @@
"segment": {
"all": "All",
"people": "People",
- "hashtags": "Hashtags"
+ "hashtags": "Hashtags",
+ "posts": "Posts"
+ },
+ "empty_state": {
+ "no_results": "No results"
},
"recent_search": "Recent searches",
"clear": "Clear"
@@ -472,10 +477,10 @@
"action": {
"follow": "followed you",
"favourite": "favorited your post",
- "reblog": "rebloged your post",
+ "reblog": "reblogged your post",
"poll": "Your poll has ended",
"mention": "mentioned you",
- "follow_request": "request to follow you"
+ "follow_request": "requested to follow you"
},
"keyobard": {
"show_everything": "Show Everything",
@@ -496,8 +501,8 @@
"dark": "Always Dark"
},
"appearance_settings": {
- "true_black_dark_mode": "True black Dark Mode",
- "disable_avatar_animation": "Disable avatar animation"
+ "true_black_dark_mode": "True black dark mode",
+ "disable_avatar_animation": "Disable animated avatars"
},
"notifications": {
"title": "Notifications",
@@ -514,16 +519,17 @@
}
},
"preference": {
- "title": "Preference",
- "using_default_browser": "Using default browser open link"
+ "title": "Preferences",
+ "using_default_browser": "Use default browser to open links"
},
- "boringzone": {
- "title": "The Boring zone",
+ "boring_zone": {
+ "title": "The Boring Zone",
+ "account_settings": "Account settings",
"terms": "Terms of Service",
"privacy": "Privacy Policy"
},
- "spicyzone": {
- "title": "The spicy zone",
+ "spicy_zone": {
+ "title": "The Spicy Zone",
"clear": "Clear Media Cache",
"signout": "Sign Out"
}
diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj
index de5aac196..aded53b0d 100644
--- a/Mastodon.xcodeproj/project.pbxproj
+++ b/Mastodon.xcodeproj/project.pbxproj
@@ -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 = ""; };
2D198642261BF09500F0B013 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = ""; };
2D198648261C0B8500F0B013 /* SearchResultSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultSection.swift; sourceTree = ""; };
- 2D198654261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewModel+LoadOldestState.swift"; sourceTree = ""; };
2D206B7125F5D27F00143C56 /* AudioContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContainerView.swift; sourceTree = ""; };
2D206B7F25F5F45E00143C56 /* UIImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.swift; sourceTree = ""; };
2D206B8525F5FB0900143C56 /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.swift; sourceTree = ""; };
@@ -753,8 +765,7 @@
2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Favorite.swift"; sourceTree = ""; };
2DF75BB825D1474100694EC8 /* ManagedObjectObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedObjectObserver.swift; sourceTree = ""; };
2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedObjectContextObjectsDidChange.swift; sourceTree = ""; };
- 2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewController+Searching.swift"; sourceTree = ""; };
- 2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchingTableViewCell.swift; sourceTree = ""; };
+ 2DFAD5362617010500F9EE7C /* SearchResultTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultTableViewCell.swift; sourceTree = ""; };
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 = ""; };
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 = ""; };
@@ -879,8 +890,6 @@
DB427DF725BAA00100D1B89D /* MastodonUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonUITests.swift; sourceTree = ""; };
DB427DF925BAA00100D1B89D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
DB44384E25E8C1FA008912A2 /* CALayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CALayer.swift; sourceTree = ""; };
- DB443CCF269415D200159B29 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = ""; };
- DB443CD1269415D800159B29 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ar; path = ar.lproj/Localizable.stringsdict; sourceTree = ""; };
DB443CD32694627B00159B29 /* AppearanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceView.swift; sourceTree = ""; };
DB44767A260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerInputView.swift; sourceTree = ""; };
DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerSection.swift; sourceTree = ""; };
@@ -911,6 +920,17 @@
DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel+LoadState.swift"; sourceTree = ""; };
DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CustomEmoji.swift"; sourceTree = ""; };
DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PlayerContainerView+MediaTypeIndicotorView.swift"; sourceTree = ""; };
+ DB4F0962269ED06300D62E92 /* SearchResultViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultViewController.swift; sourceTree = ""; };
+ DB4F0965269ED52200D62E92 /* SearchResultViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultViewModel.swift; sourceTree = ""; };
+ DB4F0967269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistoryTableHeaderView.swift; sourceTree = ""; };
+ DB4F0969269EDAD200D62E92 /* SearchResultViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchResultViewModel+State.swift"; sourceTree = ""; };
+ DB4F096B269EFA2000D62E92 /* SearchResultViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchResultViewController+StatusProvider.swift"; sourceTree = ""; };
+ DB4F097426A037F500D62E92 /* SearchHistoryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistoryViewModel.swift; sourceTree = ""; };
+ DB4F097A26A039FF00D62E92 /* SearchHistorySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistorySection.swift; sourceTree = ""; };
+ DB4F097C26A03A5B00D62E92 /* SearchHistoryItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistoryItem.swift; sourceTree = ""; };
+ DB4F097E26A03DA600D62E92 /* SearchHistoryFetchedResultController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistoryFetchedResultController.swift; sourceTree = ""; };
+ DB4FFC29269EC39600D62E92 /* SearchToSearchDetailViewControllerAnimatedTransitioning.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchToSearchDetailViewControllerAnimatedTransitioning.swift; sourceTree = ""; };
+ DB4FFC2A269EC39600D62E92 /* SearchTransitionController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchTransitionController.swift; sourceTree = ""; };
DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarBarButtonItem.swift; sourceTree = ""; };
DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarConfigurableView.swift; sourceTree = ""; };
DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SplashPreference.swift; sourceTree = ""; };
@@ -918,6 +938,9 @@
DB51D171262832380062B7A1 /* BlurHashEncode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlurHashEncode.swift; sourceTree = ""; };
DB52D33926839DD800D43133 /* ImageTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageTask.swift; sourceTree = ""; };
DB55D32F25FB630A0002F825 /* TwitterTextEditor+String.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "TwitterTextEditor+String.swift"; sourceTree = ""; };
+ DB564BCF269F2F83001E39A7 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ar; path = ar.lproj/Localizable.stringsdict; sourceTree = ""; };
+ DB564BD1269F2F8A001E39A7 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = ""; };
+ DB564BD2269F3B35001E39A7 /* StatusFilterService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusFilterService.swift; sourceTree = ""; };
DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+UITableViewDelegate.swift"; sourceTree = ""; };
DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewCellHeightCacheableContainer.swift; sourceTree = ""; };
DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Poll.swift"; sourceTree = ""; };
@@ -1137,6 +1160,9 @@
DBE3CE0C261D767100430CC6 /* FavoriteViewController+Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewController+Provider.swift"; sourceTree = ""; };
DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewControllerAspect.swift; sourceTree = ""; };
DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPreference.swift; sourceTree = ""; };
+ DBF1D24D269DAF5D00C1C08A /* SearchDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchDetailViewController.swift; sourceTree = ""; };
+ DBF1D250269DB01200C1C08A /* SearchHistoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistoryViewController.swift; sourceTree = ""; };
+ DBF1D256269DBAC600C1C08A /* SearchDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchDetailViewModel.swift; sourceTree = ""; };
DBF53F5F25C14E88008AAC7B /* Mastodon.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = Mastodon.xctestplan; path = Mastodon/Mastodon.xctestplan; sourceTree = ""; };
DBF53F6025C14E9D008AAC7B /* MastodonSDK.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MastodonSDK.xctestplan; sourceTree = ""; };
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 = "";
@@ -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 = "";
@@ -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 = "";
};
+ DB4F0964269ED06700D62E92 /* SearchResult */ = {
+ isa = PBXGroup;
+ children = (
+ DB4F0962269ED06300D62E92 /* SearchResultViewController.swift */,
+ DB4F096B269EFA2000D62E92 /* SearchResultViewController+StatusProvider.swift */,
+ DB4F0965269ED52200D62E92 /* SearchResultViewModel.swift */,
+ DB4F0969269EDAD200D62E92 /* SearchResultViewModel+State.swift */,
+ );
+ path = SearchResult;
+ sourceTree = "";
+ };
+ DB4F097626A0398000D62E92 /* Compose */ = {
+ isa = PBXGroup;
+ children = (
+ DB66729525F9F91600D60309 /* ComposeStatusSection.swift */,
+ DB36679E268ABAF20027D07F /* ComposeStatusAttachmentSection.swift */,
+ DB3667A5268AE2620027D07F /* ComposeStatusPollSection.swift */,
+ DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */,
+ DBBF1DC82652538500E5B703 /* AutoCompleteSection.swift */,
+ );
+ path = Compose;
+ sourceTree = "";
+ };
+ DB4F097726A039A200D62E92 /* Search */ = {
+ isa = PBXGroup;
+ children = (
+ 2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */,
+ 2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */,
+ 2D198648261C0B8500F0B013 /* SearchResultSection.swift */,
+ DB4F097A26A039FF00D62E92 /* SearchHistorySection.swift */,
+ );
+ path = Search;
+ sourceTree = "";
+ };
+ DB4F097826A039B400D62E92 /* Onboarding */ = {
+ isa = PBXGroup;
+ children = (
+ DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */,
+ DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */,
+ );
+ path = Onboarding;
+ sourceTree = "";
+ };
+ DB4F097926A039C400D62E92 /* Status */ = {
+ isa = PBXGroup;
+ children = (
+ 2D76319E25C1521200929FB9 /* StatusSection.swift */,
+ DB4481C525EE2ADA00BEFB67 /* PollSection.swift */,
+ 2D35237926256D920031AF25 /* NotificationSection.swift */,
+ 5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */,
+ );
+ path = Status;
+ sourceTree = "";
+ };
+ DB4F098026A0475500D62E92 /* View */ = {
+ isa = PBXGroup;
+ children = (
+ DB4F0967269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift */,
+ );
+ path = View;
+ sourceTree = "";
+ };
+ DB4FFC2D269EC39C00D62E92 /* Search */ = {
+ isa = PBXGroup;
+ children = (
+ DB4FFC2A269EC39600D62E92 /* SearchTransitionController.swift */,
+ DB4FFC29269EC39600D62E92 /* SearchToSearchDetailViewControllerAnimatedTransitioning.swift */,
+ );
+ path = Search;
+ sourceTree = "";
+ };
DB5086CB25CC0DB400C2C187 /* Preference */ = {
isa = PBXGroup;
children = (
@@ -2041,6 +2130,7 @@
children = (
DB6180E226391A4C0018D199 /* ViewControllerAnimatedTransitioning.swift */,
DB6180E726391B580018D199 /* MediaPreview */,
+ DB4FFC2D269EC39C00D62E92 /* Search */,
);
path = Transition;
sourceTree = "";
@@ -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 = "";
@@ -2620,6 +2703,7 @@
DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */,
DBA088DE26958164003EB4B2 /* UserFetchedResultsController.swift */,
DB6D9F75263587C7008423CD /* SettingFetchedResultController.swift */,
+ DB4F097E26A03DA600D62E92 /* SearchHistoryFetchedResultController.swift */,
);
path = FetchedResultsController;
sourceTree = "";
@@ -2657,6 +2741,41 @@
path = Favorite;
sourceTree = "";
};
+ DBF1D24F269DAF6100C1C08A /* SearchDetail */ = {
+ isa = PBXGroup;
+ children = (
+ 2DFAD5212616F8E300F9EE7C /* TableViewCell */,
+ DB4F0964269ED06700D62E92 /* SearchResult */,
+ DBF1D252269DB01700C1C08A /* SearchHistory */,
+ DBF1D24D269DAF5D00C1C08A /* SearchDetailViewController.swift */,
+ DBF1D256269DBAC600C1C08A /* SearchDetailViewModel.swift */,
+ );
+ path = SearchDetail;
+ sourceTree = "";
+ };
+ DBF1D252269DB01700C1C08A /* SearchHistory */ = {
+ isa = PBXGroup;
+ children = (
+ DB4F098026A0475500D62E92 /* View */,
+ DBF1D250269DB01200C1C08A /* SearchHistoryViewController.swift */,
+ DB4F097426A037F500D62E92 /* SearchHistoryViewModel.swift */,
+ );
+ path = SearchHistory;
+ sourceTree = "";
+ };
+ 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 = "";
+ };
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 = "";
};
- 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 = "";
+ sourceTree = "";
};
/* 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;
diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist
index ecde1bcc5..33fc31c57 100644
--- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist
+++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -12,7 +12,7 @@
CoreDataStack.xcscheme_^#shared#^_
orderHint
- 20
+ 28
Mastodon - ASDK.xcscheme_^#shared#^_
@@ -37,7 +37,7 @@
NotificationService.xcscheme_^#shared#^_
orderHint
- 21
+ 27
SuppressBuildableAutocreation
diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift
index f1f288fa1..5aac2b1b9 100644
--- a/Mastodon/Coordinator/SceneCoordinator.swift
+++ b/Mastodon/Coordinator/SceneCoordinator.swift
@@ -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
diff --git a/Mastodon/Diffiable/FetchedResultsController/SearchHistoryFetchedResultController.swift b/Mastodon/Diffiable/FetchedResultsController/SearchHistoryFetchedResultController.swift
new file mode 100644
index 000000000..b83bfe662
--- /dev/null
+++ b/Mastodon/Diffiable/FetchedResultsController/SearchHistoryFetchedResultController.swift
@@ -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()
+
+ let fetchedResultsController: NSFetchedResultsController
+
+ // 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, 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 }
+ }
+}
diff --git a/Mastodon/Diffiable/Item/Item.swift b/Mastodon/Diffiable/Item/Item.swift
index bde116b69..220a7fdba 100644
--- a/Mastodon/Diffiable/Item/Item.swift
+++ b/Mastodon/Diffiable/Item/Item.swift
@@ -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
+ }
+ }
+}
diff --git a/Mastodon/Diffiable/Item/NotificationItem.swift b/Mastodon/Diffiable/Item/NotificationItem.swift
index f26d2e43d..22949b3a5 100644
--- a/Mastodon/Diffiable/Item/NotificationItem.swift
+++ b/Mastodon/Diffiable/Item/NotificationItem.swift
@@ -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
+ }
+ }
+}
diff --git a/Mastodon/Diffiable/Item/SearchHistoryItem.swift b/Mastodon/Diffiable/Item/SearchHistoryItem.swift
new file mode 100644
index 000000000..de97eae34
--- /dev/null
+++ b/Mastodon/Diffiable/Item/SearchHistoryItem.swift
@@ -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)
+ }
+ }
+}
diff --git a/Mastodon/Diffiable/Item/SearchResultItem.swift b/Mastodon/Diffiable/Item/SearchResultItem.swift
index 53a36e2e5..7f57c4355 100644
--- a/Mastodon/Diffiable/Item/SearchResultItem.swift
+++ b/Mastodon/Diffiable/Item/SearchResultItem.swift
@@ -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
}
}
}
diff --git a/Mastodon/Diffiable/Item/SettingsItem.swift b/Mastodon/Diffiable/Item/SettingsItem.swift
index 7320e9fc5..e23b3f36f 100644
--- a/Mastodon/Diffiable/Item/SettingsItem.swift
+++ b/Mastodon/Diffiable/Item/SettingsItem.swift
@@ -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
diff --git a/Mastodon/Diffiable/Section/AutoCompleteSection.swift b/Mastodon/Diffiable/Section/Compose/AutoCompleteSection.swift
similarity index 100%
rename from Mastodon/Diffiable/Section/AutoCompleteSection.swift
rename to Mastodon/Diffiable/Section/Compose/AutoCompleteSection.swift
diff --git a/Mastodon/Diffiable/Section/ComposeStatusAttachmentSection.swift b/Mastodon/Diffiable/Section/Compose/ComposeStatusAttachmentSection.swift
similarity index 100%
rename from Mastodon/Diffiable/Section/ComposeStatusAttachmentSection.swift
rename to Mastodon/Diffiable/Section/Compose/ComposeStatusAttachmentSection.swift
diff --git a/Mastodon/Diffiable/Section/ComposeStatusPollSection.swift b/Mastodon/Diffiable/Section/Compose/ComposeStatusPollSection.swift
similarity index 100%
rename from Mastodon/Diffiable/Section/ComposeStatusPollSection.swift
rename to Mastodon/Diffiable/Section/Compose/ComposeStatusPollSection.swift
diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/Compose/ComposeStatusSection.swift
similarity index 100%
rename from Mastodon/Diffiable/Section/ComposeStatusSection.swift
rename to Mastodon/Diffiable/Section/Compose/ComposeStatusSection.swift
diff --git a/Mastodon/Diffiable/Section/CustomEmojiPickerSection.swift b/Mastodon/Diffiable/Section/Compose/CustomEmojiPickerSection.swift
similarity index 100%
rename from Mastodon/Diffiable/Section/CustomEmojiPickerSection.swift
rename to Mastodon/Diffiable/Section/Compose/CustomEmojiPickerSection.swift
diff --git a/Mastodon/Diffiable/Section/CategoryPickerSection.swift b/Mastodon/Diffiable/Section/Onboarding/CategoryPickerSection.swift
similarity index 100%
rename from Mastodon/Diffiable/Section/CategoryPickerSection.swift
rename to Mastodon/Diffiable/Section/Onboarding/CategoryPickerSection.swift
diff --git a/Mastodon/Diffiable/Section/PickServerSection.swift b/Mastodon/Diffiable/Section/Onboarding/PickServerSection.swift
similarity index 100%
rename from Mastodon/Diffiable/Section/PickServerSection.swift
rename to Mastodon/Diffiable/Section/Onboarding/PickServerSection.swift
diff --git a/Mastodon/Diffiable/Section/RecommendAccountSection.swift b/Mastodon/Diffiable/Section/Search/RecommendAccountSection.swift
similarity index 100%
rename from Mastodon/Diffiable/Section/RecommendAccountSection.swift
rename to Mastodon/Diffiable/Section/Search/RecommendAccountSection.swift
diff --git a/Mastodon/Diffiable/Section/RecommendHashTagSection.swift b/Mastodon/Diffiable/Section/Search/RecommendHashTagSection.swift
similarity index 100%
rename from Mastodon/Diffiable/Section/RecommendHashTagSection.swift
rename to Mastodon/Diffiable/Section/Search/RecommendHashTagSection.swift
diff --git a/Mastodon/Diffiable/Section/Search/SearchHistorySection.swift b/Mastodon/Diffiable/Section/Search/SearchHistorySection.swift
new file mode 100644
index 000000000..8f39eb6bd
--- /dev/null
+++ b/Mastodon/Diffiable/Section/Search/SearchHistorySection.swift
@@ -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 {
+ 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
+}
diff --git a/Mastodon/Diffiable/Section/Search/SearchResultSection.swift b/Mastodon/Diffiable/Section/Search/SearchResultSection.swift
new file mode 100644
index 000000000..5d26a6828
--- /dev/null
+++ b/Mastodon/Diffiable/Section/Search/SearchResultSection.swift
@@ -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 {
+ 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
+}
diff --git a/Mastodon/Diffiable/Section/SearchResultSection.swift b/Mastodon/Diffiable/Section/SearchResultSection.swift
deleted file mode 100644
index 1b9230ee0..000000000
--- a/Mastodon/Diffiable/Section/SearchResultSection.swift
+++ /dev/null
@@ -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 {
- 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
- }
- }
- }
-}
diff --git a/Mastodon/Diffiable/Section/SettingsSection.swift b/Mastodon/Diffiable/Section/SettingsSection.swift
index 46ac82a1a..4d3c13d27 100644
--- a/Mastodon/Diffiable/Section/SettingsSection.swift
+++ b/Mastodon/Diffiable/Section/SettingsSection.swift
@@ -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
}
}
}
diff --git a/Mastodon/Diffiable/Section/NotificationSection.swift b/Mastodon/Diffiable/Section/Status/NotificationSection.swift
similarity index 100%
rename from Mastodon/Diffiable/Section/NotificationSection.swift
rename to Mastodon/Diffiable/Section/Status/NotificationSection.swift
diff --git a/Mastodon/Diffiable/Section/PollSection.swift b/Mastodon/Diffiable/Section/Status/PollSection.swift
similarity index 100%
rename from Mastodon/Diffiable/Section/PollSection.swift
rename to Mastodon/Diffiable/Section/Status/PollSection.swift
diff --git a/Mastodon/Diffiable/Section/ReportSection.swift b/Mastodon/Diffiable/Section/Status/ReportSection.swift
similarity index 100%
rename from Mastodon/Diffiable/Section/ReportSection.swift
rename to Mastodon/Diffiable/Section/Status/ReportSection.swift
diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/Status/StatusSection.swift
similarity index 94%
rename from Mastodon/Diffiable/Section/StatusSection.swift
rename to Mastodon/Diffiable/Section/Status/StatusSection.swift
index 15c3f7896..e1823851a 100644
--- a/Mastodon/Diffiable/Section/StatusSection.swift
+++ b/Mastodon/Diffiable/Section/Status/StatusSection.swift
@@ -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 {
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?
+
+ 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()
+ }
+}
diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift
index 5fa285e57..90e1af8eb 100644
--- a/Mastodon/Generated/Strings.swift
+++ b/Mastodon/Generated/Strings.swift
@@ -14,15 +14,15 @@ internal enum L10n {
internal enum Common {
internal enum Alerts {
internal enum BlockDomain {
- /// Block entire domain
+ /// Block Domain
internal static let blockEntireDomain = L10n.tr("Localizable", "Common.Alerts.BlockDomain.BlockEntireDomain")
- /// Are you really, really sure you want to block the entire %@? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.
+ /// Are you really, really sure you want to block the entire %@? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain and any of your followers from that domain will be removed.
internal static func title(_ p1: Any) -> String {
return L10n.tr("Localizable", "Common.Alerts.BlockDomain.Title", String(describing: p1))
}
}
internal enum CleanCache {
- /// Successfully clean %@ cache.
+ /// Successfully cleaned %@ cache.
internal static func message(_ p1: Any) -> String {
return L10n.tr("Localizable", "Common.Alerts.CleanCache.Message", String(describing: p1))
}
@@ -42,9 +42,9 @@ internal enum L10n {
internal static let title = L10n.tr("Localizable", "Common.Alerts.DeletePost.Title")
}
internal enum DiscardPostContent {
- /// Confirm discard composed post content.
+ /// Confirm to discard composed post content.
internal static let message = L10n.tr("Localizable", "Common.Alerts.DiscardPostContent.Message")
- /// Discard Publish
+ /// Discard Draft
internal static let title = L10n.tr("Localizable", "Common.Alerts.DiscardPostContent.Title")
}
internal enum EditProfileFailure {
@@ -61,12 +61,12 @@ internal enum L10n {
internal enum AttchmentsMessage {
/// Cannot attach more than one video.
internal static let moreThanOneVideo = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.AttchmentsMessage.MoreThanOneVideo")
- /// Cannot attach a video to a status that already contains images.
+ /// Cannot attach a video to a post that already contains images.
internal static let videoAttachWithPhoto = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.AttchmentsMessage.VideoAttachWithPhoto")
}
}
internal enum SavePhotoFailure {
- /// Please enable photo libaray access permission to save photo.
+ /// Please enable the photo library access permission to save the photo.
internal static let message = L10n.tr("Localizable", "Common.Alerts.SavePhotoFailure.Message")
/// Save Photo Failure
internal static let title = L10n.tr("Localizable", "Common.Alerts.SavePhotoFailure.Title")
@@ -80,7 +80,7 @@ internal enum L10n {
internal static let confirm = L10n.tr("Localizable", "Common.Alerts.SignOut.Confirm")
/// Are you sure you want to sign out?
internal static let message = L10n.tr("Localizable", "Common.Alerts.SignOut.Message")
- /// Sign out
+ /// Sign Out
internal static let title = L10n.tr("Localizable", "Common.Alerts.SignOut.Title")
}
internal enum SignUpFailure {
@@ -239,25 +239,25 @@ internal enum L10n {
internal static let previousSection = L10n.tr("Localizable", "Common.Controls.Keyboard.SegmentedControl.PreviousSection")
}
internal enum Timeline {
- /// Next Status
+ /// Next Post
internal static let nextStatus = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.NextStatus")
- /// Open Author Profile
+ /// Open Author's Profile
internal static let openAuthorProfile = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.OpenAuthorProfile")
- /// Open Reblogger Profile
+ /// Open Reblogger's Profile
internal static let openRebloggerProfile = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.OpenRebloggerProfile")
- /// Open Status
+ /// Open Post
internal static let openStatus = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.OpenStatus")
/// Preview Image
internal static let previewImage = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.PreviewImage")
- /// Previous Status
+ /// Previous Post
internal static let previousStatus = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.PreviousStatus")
- /// Reply Status
+ /// Reply to Post
internal static let replyStatus = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.ReplyStatus")
/// Toggle Content Warning
internal static let toggleContentWarning = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.ToggleContentWarning")
- /// Toggle Status Favorite
+ /// Toggle Favorite on Post
internal static let toggleFavorite = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.ToggleFavorite")
- /// Toggle Status Reblog
+ /// Toggle Reblog on Post
internal static let toggleReblog = L10n.tr("Localizable", "Common.Controls.Keyboard.Timeline.ToggleReblog")
}
}
@@ -289,7 +289,7 @@ internal enum L10n {
internal static let reply = L10n.tr("Localizable", "Common.Controls.Status.Actions.Reply")
/// Unfavorite
internal static let unfavorite = L10n.tr("Localizable", "Common.Controls.Status.Actions.Unfavorite")
- /// Unreblog
+ /// Undo reblog
internal static let unreblog = L10n.tr("Localizable", "Common.Controls.Status.Actions.Unreblog")
}
internal enum Poll {
@@ -345,19 +345,19 @@ internal enum L10n {
}
}
internal enum Header {
- /// You can’t view this’s profile\n until they unblock you.
+ /// You can’t view this user’s profile\n until they unblock you.
internal static let blockedWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.BlockedWarning")
- /// You can’t view this profile\n until you unblock them.\nYour account looks like this to them.
+ /// You can’t view this user's profile\n until you unblock them.\nYour profile looks like this to them.
internal static let blockingWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.BlockingWarning")
- /// No Status Found
+ /// No Post Found
internal static let noStatusFound = L10n.tr("Localizable", "Common.Controls.Timeline.Header.NoStatusFound")
- /// This account has been suspended.
+ /// This user has been suspended.
internal static let suspendedWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.SuspendedWarning")
/// You can’t view %@’s profile\n until they unblock you.
internal static func userBlockedWarning(_ p1: Any) -> String {
return L10n.tr("Localizable", "Common.Controls.Timeline.Header.UserBlockedWarning", String(describing: p1))
}
- /// You can’t view %@’s profile\n until you unblock them.\nYour account looks like this to them.
+ /// You can’t view %@’s profile\n until you unblock them.\nYour profile looks like this to them.
internal static func userBlockingWarning(_ p1: Any) -> String {
return L10n.tr("Localizable", "Common.Controls.Timeline.Header.UserBlockingWarning", String(describing: p1))
}
@@ -397,15 +397,15 @@ internal enum L10n {
return L10n.tr("Localizable", "Scene.Compose.ReplyingToUser", String(describing: p1))
}
internal enum Accessibility {
- /// Append attachment
+ /// Add Attachment
internal static let appendAttachment = L10n.tr("Localizable", "Scene.Compose.Accessibility.AppendAttachment")
- /// Append poll
+ /// Add Poll
internal static let appendPoll = L10n.tr("Localizable", "Scene.Compose.Accessibility.AppendPoll")
- /// Custom emoji picker
+ /// Custom Emoji Picker
internal static let customEmojiPicker = L10n.tr("Localizable", "Scene.Compose.Accessibility.CustomEmojiPicker")
- /// Disable content warning
+ /// Disable Content Warning
internal static let disableContentWarning = L10n.tr("Localizable", "Scene.Compose.Accessibility.DisableContentWarning")
- /// Enable content warning
+ /// Enable Content Warning
internal static let enableContentWarning = L10n.tr("Localizable", "Scene.Compose.Accessibility.EnableContentWarning")
/// Input limit exceeds %ld
internal static func inputLimitExceedsCount(_ p1: Int) -> String {
@@ -415,9 +415,9 @@ internal enum L10n {
internal static func inputLimitRemainsCount(_ p1: Int) -> String {
return L10n.tr("Localizable", "Scene.Compose.Accessibility.InputLimitRemainsCount", p1)
}
- /// Post visibility menu
+ /// Post Visibility Menu
internal static let postVisibilityMenu = L10n.tr("Localizable", "Scene.Compose.Accessibility.PostVisibilityMenu")
- /// Remove poll
+ /// Remove Poll
internal static let removePoll = L10n.tr("Localizable", "Scene.Compose.Accessibility.RemovePoll")
}
internal enum Attachment {
@@ -425,9 +425,9 @@ internal enum L10n {
internal static func attachmentBroken(_ p1: Any) -> String {
return L10n.tr("Localizable", "Scene.Compose.Attachment.AttachmentBroken", String(describing: p1))
}
- /// Describe photo for low vision people...
+ /// Describe the photo for the visually-impaired...
internal static let descriptionPhoto = L10n.tr("Localizable", "Scene.Compose.Attachment.DescriptionPhoto")
- /// Describe what’s happening for low vision people...
+ /// Describe the video for the visually-impaired...
internal static let descriptionVideo = L10n.tr("Localizable", "Scene.Compose.Attachment.DescriptionVideo")
/// photo
internal static let photo = L10n.tr("Localizable", "Scene.Compose.Attachment.Photo")
@@ -443,7 +443,7 @@ internal enum L10n {
internal static let placeholder = L10n.tr("Localizable", "Scene.Compose.ContentWarning.Placeholder")
}
internal enum Keyboard {
- /// Append Attachment - %@
+ /// Add Attachment - %@
internal static func appendAttachmentEntry(_ p1: Any) -> String {
return L10n.tr("Localizable", "Scene.Compose.Keyboard.AppendAttachmentEntry", String(describing: p1))
}
@@ -569,13 +569,13 @@ internal enum L10n {
internal static let favourite = L10n.tr("Localizable", "Scene.Notification.Action.Favourite")
/// followed you
internal static let follow = L10n.tr("Localizable", "Scene.Notification.Action.Follow")
- /// request to follow you
+ /// requested to follow you
internal static let followRequest = L10n.tr("Localizable", "Scene.Notification.Action.FollowRequest")
/// mentioned you
internal static let mention = L10n.tr("Localizable", "Scene.Notification.Action.Mention")
/// Your poll has ended
internal static let poll = L10n.tr("Localizable", "Scene.Notification.Action.Poll")
- /// rebloged your post
+ /// reblogged your post
internal static let reblog = L10n.tr("Localizable", "Scene.Notification.Action.Reblog")
}
internal enum Keyobard {
@@ -636,7 +636,7 @@ internal enum L10n {
}
internal enum RelationshipActionAlert {
internal enum ConfirmUnblockUsre {
- /// Confirm unblock %@
+ /// Confirm to unblock %@
internal static func message(_ p1: Any) -> String {
return L10n.tr("Localizable", "Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message", String(describing: p1))
}
@@ -644,7 +644,7 @@ internal enum L10n {
internal static let title = L10n.tr("Localizable", "Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title")
}
internal enum ConfirmUnmuteUser {
- /// Confirm unmute %@
+ /// Confirm to unmute %@
internal static func message(_ p1: Any) -> String {
return L10n.tr("Localizable", "Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message", String(describing: p1))
}
@@ -692,7 +692,7 @@ internal enum L10n {
internal static func blank(_ p1: Any) -> String {
return L10n.tr("Localizable", "Scene.Register.Error.Reason.Blank", String(describing: p1))
}
- /// %@ contains a disallowed e-mail provider
+ /// %@ contains a disallowed email provider
internal static func blocked(_ p1: Any) -> String {
return L10n.tr("Localizable", "Scene.Register.Error.Reason.Blocked", String(describing: p1))
}
@@ -726,7 +726,7 @@ internal enum L10n {
}
}
internal enum Special {
- /// This is not a valid e-mail address
+ /// This is not a valid email address
internal static let emailInvalid = L10n.tr("Localizable", "Scene.Register.Error.Special.EmailInvalid")
/// Password is too short (must be at least 8 characters)
internal static let passwordTooShort = L10n.tr("Localizable", "Scene.Register.Error.Special.PasswordTooShort")
@@ -788,6 +788,8 @@ internal enum L10n {
}
}
internal enum Search {
+ /// Search
+ internal static let title = L10n.tr("Localizable", "Scene.Search.Title")
internal enum Recommend {
/// See All
internal static let buttonText = L10n.tr("Localizable", "Scene.Search.Recommend.ButtonText")
@@ -800,27 +802,31 @@ internal enum L10n {
internal static let title = L10n.tr("Localizable", "Scene.Search.Recommend.Accounts.Title")
}
internal enum HashTag {
- /// Hashtags that are getting quite a bit of attention among people you follow
+ /// Hashtags that are getting quite a bit of attention
internal static let description = L10n.tr("Localizable", "Scene.Search.Recommend.HashTag.Description")
/// %@ people are talking
internal static func peopleTalking(_ p1: Any) -> String {
return L10n.tr("Localizable", "Scene.Search.Recommend.HashTag.PeopleTalking", String(describing: p1))
}
- /// Trending in your timeline
+ /// Trending on Mastodon
internal static let title = L10n.tr("Localizable", "Scene.Search.Recommend.HashTag.Title")
}
}
- internal enum Searchbar {
+ internal enum SearchBar {
/// Cancel
- internal static let cancel = L10n.tr("Localizable", "Scene.Search.Searchbar.Cancel")
+ internal static let cancel = L10n.tr("Localizable", "Scene.Search.SearchBar.Cancel")
/// Search hashtags and users
- internal static let placeholder = L10n.tr("Localizable", "Scene.Search.Searchbar.Placeholder")
+ internal static let placeholder = L10n.tr("Localizable", "Scene.Search.SearchBar.Placeholder")
}
internal enum Searching {
/// Clear
internal static let clear = L10n.tr("Localizable", "Scene.Search.Searching.Clear")
/// Recent searches
internal static let recentSearch = L10n.tr("Localizable", "Scene.Search.Searching.RecentSearch")
+ internal enum EmptyState {
+ /// No results
+ internal static let noResults = L10n.tr("Localizable", "Scene.Search.Searching.EmptyState.NoResults")
+ }
internal enum Segment {
/// All
internal static let all = L10n.tr("Localizable", "Scene.Search.Searching.Segment.All")
@@ -828,6 +834,8 @@ internal enum L10n {
internal static let hashtags = L10n.tr("Localizable", "Scene.Search.Searching.Segment.Hashtags")
/// People
internal static let people = L10n.tr("Localizable", "Scene.Search.Searching.Segment.People")
+ /// Posts
+ internal static let posts = L10n.tr("Localizable", "Scene.Search.Searching.Segment.Posts")
}
}
}
@@ -871,7 +879,7 @@ internal enum L10n {
}
}
internal enum EmptyState {
- /// Something went wrong while loading data. Check your internet connection.
+ /// Something went wrong while loading the data. Check your internet connection.
internal static let badNetwork = L10n.tr("Localizable", "Scene.ServerPicker.EmptyState.BadNetwork")
/// Finding available servers...
internal static let findingServers = L10n.tr("Localizable", "Scene.ServerPicker.EmptyState.FindingServers")
@@ -936,18 +944,20 @@ internal enum L10n {
internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Appearance.Title")
}
internal enum AppearanceSettings {
- /// Disable avatar animation
+ /// Disable animated avatars
internal static let disableAvatarAnimation = L10n.tr("Localizable", "Scene.Settings.Section.AppearanceSettings.DisableAvatarAnimation")
- /// True black Dark Mode
+ /// True black dark mode
internal static let trueBlackDarkMode = L10n.tr("Localizable", "Scene.Settings.Section.AppearanceSettings.TrueBlackDarkMode")
}
- internal enum Boringzone {
+ internal enum BoringZone {
+ /// Account settings
+ internal static let accountSettings = L10n.tr("Localizable", "Scene.Settings.Section.BoringZone.AccountSettings")
/// Privacy Policy
- internal static let privacy = L10n.tr("Localizable", "Scene.Settings.Section.Boringzone.Privacy")
+ internal static let privacy = L10n.tr("Localizable", "Scene.Settings.Section.BoringZone.Privacy")
/// Terms of Service
- internal static let terms = L10n.tr("Localizable", "Scene.Settings.Section.Boringzone.Terms")
- /// The Boring zone
- internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Boringzone.Title")
+ internal static let terms = L10n.tr("Localizable", "Scene.Settings.Section.BoringZone.Terms")
+ /// The Boring Zone
+ internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.BoringZone.Title")
}
internal enum Notifications {
/// Reblogs my post
@@ -974,18 +984,18 @@ internal enum L10n {
}
}
internal enum Preference {
- /// Preference
+ /// Preferences
internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Preference.Title")
- /// Using default browser open link
+ /// Use default browser to open links
internal static let usingDefaultBrowser = L10n.tr("Localizable", "Scene.Settings.Section.Preference.UsingDefaultBrowser")
}
- internal enum Spicyzone {
+ internal enum SpicyZone {
/// Clear Media Cache
- internal static let clear = L10n.tr("Localizable", "Scene.Settings.Section.Spicyzone.Clear")
+ internal static let clear = L10n.tr("Localizable", "Scene.Settings.Section.SpicyZone.Clear")
/// Sign Out
- internal static let signout = L10n.tr("Localizable", "Scene.Settings.Section.Spicyzone.Signout")
- /// The spicy zone
- internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Spicyzone.Title")
+ internal static let signout = L10n.tr("Localizable", "Scene.Settings.Section.SpicyZone.Signout")
+ /// The Spicy Zone
+ internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.SpicyZone.Title")
}
}
}
diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDataSourcePrefetching.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDataSourcePrefetching.swift
index 5d6e81d51..537f10c8c 100644
--- a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDataSourcePrefetching.swift
+++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDataSourcePrefetching.swift
@@ -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)
+ }
}
diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider.swift b/Mastodon/Protocol/StatusProvider/StatusProvider.swift
index cc0b690f6..3497fd7a8 100644
--- a/Mastodon/Protocol/StatusProvider/StatusProvider.swift
+++ b/Mastodon/Protocol/StatusProvider/StatusProvider.swift
@@ -22,10 +22,16 @@ protocol StatusProvider: NeedsDependency & DisposeBagCollectable & UIViewControl
// sync
var managedObjectContext: NSManagedObjectContext { get }
+
+ @available(*, deprecated)
var tableViewDiffableDataSource: UITableViewDiffableDataSource? { 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
+}
diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift
index 73952c8c3..0ae8a3ca8 100644
--- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift
+++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift
@@ -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)
diff --git a/Mastodon/Protocol/StatusTableViewControllerAspect.swift b/Mastodon/Protocol/StatusTableViewControllerAspect.swift
index c118b1206..fbaf76650 100644
--- a/Mastodon/Protocol/StatusTableViewControllerAspect.swift
+++ b/Mastodon/Protocol/StatusTableViewControllerAspect.swift
@@ -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:)
diff --git a/Mastodon/Resources/ar.lproj/Localizable.strings b/Mastodon/Resources/ar.lproj/Localizable.strings
index 63a389a9e..1bf754323 100644
--- a/Mastodon/Resources/ar.lproj/Localizable.strings
+++ b/Mastodon/Resources/ar.lproj/Localizable.strings
@@ -1,26 +1,26 @@
-"Common.Alerts.BlockDomain.BlockEntireDomain" = "Block entire domain";
-"Common.Alerts.BlockDomain.Title" = "Are you really, really sure you want to block the entire %@? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.";
-"Common.Alerts.CleanCache.Message" = "Successfully clean %@ cache.";
+"Common.Alerts.BlockDomain.BlockEntireDomain" = "Block Domain";
+"Common.Alerts.BlockDomain.Title" = "Are you really, really sure you want to block the entire %@? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain and any of your followers from that domain will be removed.";
+"Common.Alerts.CleanCache.Message" = "Successfully cleaned %@ cache.";
"Common.Alerts.CleanCache.Title" = "Clean Cache";
"Common.Alerts.Common.PleaseTryAgain" = "Please try again.";
"Common.Alerts.Common.PleaseTryAgainLater" = "Please try again later.";
"Common.Alerts.DeletePost.Delete" = "Delete";
"Common.Alerts.DeletePost.Title" = "Are you sure you want to delete this post?";
-"Common.Alerts.DiscardPostContent.Message" = "Confirm discard composed post content.";
-"Common.Alerts.DiscardPostContent.Title" = "Discard Publish";
+"Common.Alerts.DiscardPostContent.Message" = "Confirm to discard composed post content.";
+"Common.Alerts.DiscardPostContent.Title" = "Discard Draft";
"Common.Alerts.EditProfileFailure.Message" = "Cannot edit profile. Please try again.";
"Common.Alerts.EditProfileFailure.Title" = "Edit Profile Error";
"Common.Alerts.PublishPostFailure.AttchmentsMessage.MoreThanOneVideo" = "Cannot attach more than one video.";
-"Common.Alerts.PublishPostFailure.AttchmentsMessage.VideoAttachWithPhoto" = "Cannot attach a video to a status that already contains images.";
+"Common.Alerts.PublishPostFailure.AttchmentsMessage.VideoAttachWithPhoto" = "Cannot attach a video to a post that already contains images.";
"Common.Alerts.PublishPostFailure.Message" = "Failed to publish the post.
Please check your internet connection.";
"Common.Alerts.PublishPostFailure.Title" = "Publish Failure";
-"Common.Alerts.SavePhotoFailure.Message" = "Please enable photo libaray access permission to save photo.";
+"Common.Alerts.SavePhotoFailure.Message" = "Please enable the photo library access permission to save the photo.";
"Common.Alerts.SavePhotoFailure.Title" = "Save Photo Failure";
"Common.Alerts.ServerError.Title" = "Server Error";
"Common.Alerts.SignOut.Confirm" = "Sign Out";
"Common.Alerts.SignOut.Message" = "Are you sure you want to sign out?";
-"Common.Alerts.SignOut.Title" = "Sign out";
+"Common.Alerts.SignOut.Title" = "Sign Out";
"Common.Alerts.SignUpFailure.Title" = "Sign Up Failure";
"Common.Alerts.VoteFailure.PollExpired" = "The poll has expired";
"Common.Alerts.VoteFailure.Title" = "Vote Failure";
@@ -81,22 +81,22 @@ Please check your internet connection.";
"Common.Controls.Keyboard.Common.SwitchToTab" = "Switch to %@";
"Common.Controls.Keyboard.SegmentedControl.NextSection" = "Next Section";
"Common.Controls.Keyboard.SegmentedControl.PreviousSection" = "Previous Section";
-"Common.Controls.Keyboard.Timeline.NextStatus" = "Next Status";
-"Common.Controls.Keyboard.Timeline.OpenAuthorProfile" = "Open Author Profile";
-"Common.Controls.Keyboard.Timeline.OpenRebloggerProfile" = "Open Reblogger Profile";
-"Common.Controls.Keyboard.Timeline.OpenStatus" = "Open Status";
+"Common.Controls.Keyboard.Timeline.NextStatus" = "Next Post";
+"Common.Controls.Keyboard.Timeline.OpenAuthorProfile" = "Open Author's Profile";
+"Common.Controls.Keyboard.Timeline.OpenRebloggerProfile" = "Open Reblogger's Profile";
+"Common.Controls.Keyboard.Timeline.OpenStatus" = "Open Post";
"Common.Controls.Keyboard.Timeline.PreviewImage" = "Preview Image";
-"Common.Controls.Keyboard.Timeline.PreviousStatus" = "Previous Status";
-"Common.Controls.Keyboard.Timeline.ReplyStatus" = "Reply Status";
+"Common.Controls.Keyboard.Timeline.PreviousStatus" = "Previous Post";
+"Common.Controls.Keyboard.Timeline.ReplyStatus" = "Reply to Post";
"Common.Controls.Keyboard.Timeline.ToggleContentWarning" = "Toggle Content Warning";
-"Common.Controls.Keyboard.Timeline.ToggleFavorite" = "Toggle Status Favorite";
-"Common.Controls.Keyboard.Timeline.ToggleReblog" = "Toggle Status Reblog";
+"Common.Controls.Keyboard.Timeline.ToggleFavorite" = "Toggle Favorite on Post";
+"Common.Controls.Keyboard.Timeline.ToggleReblog" = "Toggle Reblog on Post";
"Common.Controls.Status.Actions.Favorite" = "Favorite";
"Common.Controls.Status.Actions.Menu" = "Menu";
"Common.Controls.Status.Actions.Reblog" = "Reblog";
"Common.Controls.Status.Actions.Reply" = "Reply";
"Common.Controls.Status.Actions.Unfavorite" = "Unfavorite";
-"Common.Controls.Status.Actions.Unreblog" = "Unreblog";
+"Common.Controls.Status.Actions.Unreblog" = "Undo reblog";
"Common.Controls.Status.ContentWarning" = "Content Warning";
"Common.Controls.Status.MediaContentWarning" = "Tap anywhere to reveal";
"Common.Controls.Status.Poll.Closed" = "Closed";
@@ -120,44 +120,44 @@ Please check your internet connection.";
"Common.Controls.Timeline.Accessibility.CountReblogs" = "%@ reblogs";
"Common.Controls.Timeline.Accessibility.CountReplies" = "%@ replies";
"Common.Controls.Timeline.Filtered" = "Filtered";
-"Common.Controls.Timeline.Header.BlockedWarning" = "You can’t view this’s profile
+"Common.Controls.Timeline.Header.BlockedWarning" = "You can’t view this user’s profile
until they unblock you.";
-"Common.Controls.Timeline.Header.BlockingWarning" = "You can’t view this profile
+"Common.Controls.Timeline.Header.BlockingWarning" = "You can’t view this user's profile
until you unblock them.
-Your account looks like this to them.";
-"Common.Controls.Timeline.Header.NoStatusFound" = "No Status Found";
-"Common.Controls.Timeline.Header.SuspendedWarning" = "This account has been suspended.";
+Your profile looks like this to them.";
+"Common.Controls.Timeline.Header.NoStatusFound" = "No Post Found";
+"Common.Controls.Timeline.Header.SuspendedWarning" = "This user has been suspended.";
"Common.Controls.Timeline.Header.UserBlockedWarning" = "You can’t view %@’s profile
until they unblock you.";
"Common.Controls.Timeline.Header.UserBlockingWarning" = "You can’t view %@’s profile
until you unblock them.
-Your account looks like this to them.";
+Your profile looks like this to them.";
"Common.Controls.Timeline.Header.UserSuspendedWarning" = "%@’s account has been suspended.";
"Common.Controls.Timeline.Loader.LoadMissingPosts" = "Load missing posts";
"Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts...";
"Common.Controls.Timeline.Loader.ShowMoreReplies" = "Show more replies";
"Common.Controls.Timeline.Timestamp.Now" = "Now";
"Common.Controls.Timeline.Timestamp.TimeAgo" = "%@ ago";
-"Scene.Compose.Accessibility.AppendAttachment" = "Append attachment";
-"Scene.Compose.Accessibility.AppendPoll" = "Append poll";
-"Scene.Compose.Accessibility.CustomEmojiPicker" = "Custom emoji picker";
-"Scene.Compose.Accessibility.DisableContentWarning" = "Disable content warning";
-"Scene.Compose.Accessibility.EnableContentWarning" = "Enable content warning";
+"Scene.Compose.Accessibility.AppendAttachment" = "Add Attachment";
+"Scene.Compose.Accessibility.AppendPoll" = "Add Poll";
+"Scene.Compose.Accessibility.CustomEmojiPicker" = "Custom Emoji Picker";
+"Scene.Compose.Accessibility.DisableContentWarning" = "Disable Content Warning";
+"Scene.Compose.Accessibility.EnableContentWarning" = "Enable Content Warning";
"Scene.Compose.Accessibility.InputLimitExceedsCount" = "Input limit exceeds %ld";
"Scene.Compose.Accessibility.InputLimitRemainsCount" = "Input limit remains %ld";
-"Scene.Compose.Accessibility.PostVisibilityMenu" = "Post visibility menu";
-"Scene.Compose.Accessibility.RemovePoll" = "Remove poll";
+"Scene.Compose.Accessibility.PostVisibilityMenu" = "Post Visibility Menu";
+"Scene.Compose.Accessibility.RemovePoll" = "Remove Poll";
"Scene.Compose.Attachment.AttachmentBroken" = "This %@ is broken and can’t be
uploaded to Mastodon.";
-"Scene.Compose.Attachment.DescriptionPhoto" = "Describe photo for low vision people...";
-"Scene.Compose.Attachment.DescriptionVideo" = "Describe what’s happening for low vision people...";
+"Scene.Compose.Attachment.DescriptionPhoto" = "Describe the photo for the visually-impaired...";
+"Scene.Compose.Attachment.DescriptionVideo" = "Describe the video for the visually-impaired...";
"Scene.Compose.Attachment.Photo" = "photo";
"Scene.Compose.Attachment.Video" = "video";
"Scene.Compose.AutoComplete.SpaceToAdd" = "Space to add";
"Scene.Compose.ComposeAction" = "Publish";
"Scene.Compose.ContentInputPlaceholder" = "Type or paste what’s on your mind";
"Scene.Compose.ContentWarning.Placeholder" = "Write an accurate warning here...";
-"Scene.Compose.Keyboard.AppendAttachmentEntry" = "Append Attachment - %@";
+"Scene.Compose.Keyboard.AppendAttachmentEntry" = "Add Attachment - %@";
"Scene.Compose.Keyboard.DiscardPost" = "Discard Post";
"Scene.Compose.Keyboard.PublishPost" = "Publish Post";
"Scene.Compose.Keyboard.SelectVisibilityEntry" = "Select Visibility - %@";
@@ -202,10 +202,10 @@ tap the link to confirm your account.";
"Scene.HomeTimeline.Title" = "Home";
"Scene.Notification.Action.Favourite" = "favorited your post";
"Scene.Notification.Action.Follow" = "followed you";
-"Scene.Notification.Action.FollowRequest" = "request to follow you";
+"Scene.Notification.Action.FollowRequest" = "requested to follow you";
"Scene.Notification.Action.Mention" = "mentioned you";
"Scene.Notification.Action.Poll" = "Your poll has ended";
-"Scene.Notification.Action.Reblog" = "rebloged your post";
+"Scene.Notification.Action.Reblog" = "reblogged your post";
"Scene.Notification.Keyobard.ShowEverything" = "Show Everything";
"Scene.Notification.Keyobard.ShowMentions" = "Show Mentions";
"Scene.Notification.Title.Everything" = "Everything";
@@ -222,9 +222,9 @@ tap the link to confirm your account.";
"Scene.Profile.Fields.AddRow" = "Add Row";
"Scene.Profile.Fields.Placeholder.Content" = "Content";
"Scene.Profile.Fields.Placeholder.Label" = "Label";
-"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message" = "Confirm unblock %@";
+"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message" = "Confirm to unblock %@";
"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title" = "Unblock Account";
-"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Confirm unmute %@";
+"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Confirm to unmute %@";
"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Title" = "Unmute Account";
"Scene.Profile.SegmentedControl.Media" = "Media";
"Scene.Profile.SegmentedControl.Posts" = "Posts";
@@ -238,7 +238,7 @@ tap the link to confirm your account.";
"Scene.Register.Error.Item.Username" = "Username";
"Scene.Register.Error.Reason.Accepted" = "%@ must be accepted";
"Scene.Register.Error.Reason.Blank" = "%@ is required";
-"Scene.Register.Error.Reason.Blocked" = "%@ contains a disallowed e-mail provider";
+"Scene.Register.Error.Reason.Blocked" = "%@ contains a disallowed email provider";
"Scene.Register.Error.Reason.Inclusion" = "%@ is not a supported value";
"Scene.Register.Error.Reason.Invalid" = "%@ is invalid";
"Scene.Register.Error.Reason.Reserved" = "%@ is a reserved keyword";
@@ -246,7 +246,7 @@ tap the link to confirm your account.";
"Scene.Register.Error.Reason.TooLong" = "%@ is too long";
"Scene.Register.Error.Reason.TooShort" = "%@ is too short";
"Scene.Register.Error.Reason.Unreachable" = "%@ does not seem to exist";
-"Scene.Register.Error.Special.EmailInvalid" = "This is not a valid e-mail address";
+"Scene.Register.Error.Special.EmailInvalid" = "This is not a valid email address";
"Scene.Register.Error.Special.PasswordTooShort" = "Password is too short (must be at least 8 characters)";
"Scene.Register.Error.Special.UsernameInvalid" = "Username must only contain alphanumeric characters and underscores";
"Scene.Register.Error.Special.UsernameTooLong" = "Username is too long (can’t be longer than 30 characters)";
@@ -271,16 +271,19 @@ tap the link to confirm your account.";
"Scene.Search.Recommend.Accounts.Follow" = "Follow";
"Scene.Search.Recommend.Accounts.Title" = "Accounts you might like";
"Scene.Search.Recommend.ButtonText" = "See All";
-"Scene.Search.Recommend.HashTag.Description" = "Hashtags that are getting quite a bit of attention among people you follow";
+"Scene.Search.Recommend.HashTag.Description" = "Hashtags that are getting quite a bit of attention";
"Scene.Search.Recommend.HashTag.PeopleTalking" = "%@ people are talking";
-"Scene.Search.Recommend.HashTag.Title" = "Trending in your timeline";
-"Scene.Search.Searchbar.Cancel" = "Cancel";
-"Scene.Search.Searchbar.Placeholder" = "Search hashtags and users";
+"Scene.Search.Recommend.HashTag.Title" = "Trending on Mastodon";
+"Scene.Search.SearchBar.Cancel" = "Cancel";
+"Scene.Search.SearchBar.Placeholder" = "Search hashtags and users";
"Scene.Search.Searching.Clear" = "Clear";
+"Scene.Search.Searching.EmptyState.NoResults" = "No results";
"Scene.Search.Searching.RecentSearch" = "Recent searches";
"Scene.Search.Searching.Segment.All" = "All";
"Scene.Search.Searching.Segment.Hashtags" = "Hashtags";
"Scene.Search.Searching.Segment.People" = "People";
+"Scene.Search.Searching.Segment.Posts" = "Posts";
+"Scene.Search.Title" = "Search";
"Scene.ServerPicker.Button.Category.Academia" = "academia";
"Scene.ServerPicker.Button.Category.Activism" = "activism";
"Scene.ServerPicker.Button.Category.All" = "All";
@@ -297,7 +300,7 @@ tap the link to confirm your account.";
"Scene.ServerPicker.Button.Category.Tech" = "tech";
"Scene.ServerPicker.Button.SeeLess" = "See Less";
"Scene.ServerPicker.Button.SeeMore" = "See More";
-"Scene.ServerPicker.EmptyState.BadNetwork" = "Something went wrong while loading data. Check your internet connection.";
+"Scene.ServerPicker.EmptyState.BadNetwork" = "Something went wrong while loading the data. Check your internet connection.";
"Scene.ServerPicker.EmptyState.FindingServers" = "Finding available servers...";
"Scene.ServerPicker.EmptyState.NoResults" = "No results";
"Scene.ServerPicker.Input.Placeholder" = "Find a server or join your own...";
@@ -318,11 +321,12 @@ any server.";
"Scene.Settings.Section.Appearance.Dark" = "Always Dark";
"Scene.Settings.Section.Appearance.Light" = "Always Light";
"Scene.Settings.Section.Appearance.Title" = "Appearance";
-"Scene.Settings.Section.AppearanceSettings.DisableAvatarAnimation" = "Disable avatar animation";
-"Scene.Settings.Section.AppearanceSettings.TrueBlackDarkMode" = "True black Dark Mode";
-"Scene.Settings.Section.Boringzone.Privacy" = "Privacy Policy";
-"Scene.Settings.Section.Boringzone.Terms" = "Terms of Service";
-"Scene.Settings.Section.Boringzone.Title" = "The Boring zone";
+"Scene.Settings.Section.AppearanceSettings.DisableAvatarAnimation" = "Disable animated avatars";
+"Scene.Settings.Section.AppearanceSettings.TrueBlackDarkMode" = "True black dark mode";
+"Scene.Settings.Section.BoringZone.AccountSettings" = "Account settings";
+"Scene.Settings.Section.BoringZone.Privacy" = "Privacy Policy";
+"Scene.Settings.Section.BoringZone.Terms" = "Terms of Service";
+"Scene.Settings.Section.BoringZone.Title" = "The Boring Zone";
"Scene.Settings.Section.Notifications.Boosts" = "Reblogs my post";
"Scene.Settings.Section.Notifications.Favorites" = "Favorites my post";
"Scene.Settings.Section.Notifications.Follows" = "Follows me";
@@ -333,11 +337,11 @@ any server.";
"Scene.Settings.Section.Notifications.Trigger.Follower" = "a follower";
"Scene.Settings.Section.Notifications.Trigger.Noone" = "no one";
"Scene.Settings.Section.Notifications.Trigger.Title" = "Notify me when";
-"Scene.Settings.Section.Preference.Title" = "Preference";
-"Scene.Settings.Section.Preference.UsingDefaultBrowser" = "Using default browser open link";
-"Scene.Settings.Section.Spicyzone.Clear" = "Clear Media Cache";
-"Scene.Settings.Section.Spicyzone.Signout" = "Sign Out";
-"Scene.Settings.Section.Spicyzone.Title" = "The spicy zone";
+"Scene.Settings.Section.Preference.Title" = "Preferences";
+"Scene.Settings.Section.Preference.UsingDefaultBrowser" = "Use default browser to open links";
+"Scene.Settings.Section.SpicyZone.Clear" = "Clear Media Cache";
+"Scene.Settings.Section.SpicyZone.Signout" = "Sign Out";
+"Scene.Settings.Section.SpicyZone.Title" = "The Spicy Zone";
"Scene.Settings.Title" = "Settings";
"Scene.SuggestionAccount.FollowExplain" = "When you follow someone, you’ll see their posts in your home feed.";
"Scene.SuggestionAccount.Title" = "Find People to Follow";
diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings
index 63a389a9e..1bf754323 100644
--- a/Mastodon/Resources/en.lproj/Localizable.strings
+++ b/Mastodon/Resources/en.lproj/Localizable.strings
@@ -1,26 +1,26 @@
-"Common.Alerts.BlockDomain.BlockEntireDomain" = "Block entire domain";
-"Common.Alerts.BlockDomain.Title" = "Are you really, really sure you want to block the entire %@? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.";
-"Common.Alerts.CleanCache.Message" = "Successfully clean %@ cache.";
+"Common.Alerts.BlockDomain.BlockEntireDomain" = "Block Domain";
+"Common.Alerts.BlockDomain.Title" = "Are you really, really sure you want to block the entire %@? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain and any of your followers from that domain will be removed.";
+"Common.Alerts.CleanCache.Message" = "Successfully cleaned %@ cache.";
"Common.Alerts.CleanCache.Title" = "Clean Cache";
"Common.Alerts.Common.PleaseTryAgain" = "Please try again.";
"Common.Alerts.Common.PleaseTryAgainLater" = "Please try again later.";
"Common.Alerts.DeletePost.Delete" = "Delete";
"Common.Alerts.DeletePost.Title" = "Are you sure you want to delete this post?";
-"Common.Alerts.DiscardPostContent.Message" = "Confirm discard composed post content.";
-"Common.Alerts.DiscardPostContent.Title" = "Discard Publish";
+"Common.Alerts.DiscardPostContent.Message" = "Confirm to discard composed post content.";
+"Common.Alerts.DiscardPostContent.Title" = "Discard Draft";
"Common.Alerts.EditProfileFailure.Message" = "Cannot edit profile. Please try again.";
"Common.Alerts.EditProfileFailure.Title" = "Edit Profile Error";
"Common.Alerts.PublishPostFailure.AttchmentsMessage.MoreThanOneVideo" = "Cannot attach more than one video.";
-"Common.Alerts.PublishPostFailure.AttchmentsMessage.VideoAttachWithPhoto" = "Cannot attach a video to a status that already contains images.";
+"Common.Alerts.PublishPostFailure.AttchmentsMessage.VideoAttachWithPhoto" = "Cannot attach a video to a post that already contains images.";
"Common.Alerts.PublishPostFailure.Message" = "Failed to publish the post.
Please check your internet connection.";
"Common.Alerts.PublishPostFailure.Title" = "Publish Failure";
-"Common.Alerts.SavePhotoFailure.Message" = "Please enable photo libaray access permission to save photo.";
+"Common.Alerts.SavePhotoFailure.Message" = "Please enable the photo library access permission to save the photo.";
"Common.Alerts.SavePhotoFailure.Title" = "Save Photo Failure";
"Common.Alerts.ServerError.Title" = "Server Error";
"Common.Alerts.SignOut.Confirm" = "Sign Out";
"Common.Alerts.SignOut.Message" = "Are you sure you want to sign out?";
-"Common.Alerts.SignOut.Title" = "Sign out";
+"Common.Alerts.SignOut.Title" = "Sign Out";
"Common.Alerts.SignUpFailure.Title" = "Sign Up Failure";
"Common.Alerts.VoteFailure.PollExpired" = "The poll has expired";
"Common.Alerts.VoteFailure.Title" = "Vote Failure";
@@ -81,22 +81,22 @@ Please check your internet connection.";
"Common.Controls.Keyboard.Common.SwitchToTab" = "Switch to %@";
"Common.Controls.Keyboard.SegmentedControl.NextSection" = "Next Section";
"Common.Controls.Keyboard.SegmentedControl.PreviousSection" = "Previous Section";
-"Common.Controls.Keyboard.Timeline.NextStatus" = "Next Status";
-"Common.Controls.Keyboard.Timeline.OpenAuthorProfile" = "Open Author Profile";
-"Common.Controls.Keyboard.Timeline.OpenRebloggerProfile" = "Open Reblogger Profile";
-"Common.Controls.Keyboard.Timeline.OpenStatus" = "Open Status";
+"Common.Controls.Keyboard.Timeline.NextStatus" = "Next Post";
+"Common.Controls.Keyboard.Timeline.OpenAuthorProfile" = "Open Author's Profile";
+"Common.Controls.Keyboard.Timeline.OpenRebloggerProfile" = "Open Reblogger's Profile";
+"Common.Controls.Keyboard.Timeline.OpenStatus" = "Open Post";
"Common.Controls.Keyboard.Timeline.PreviewImage" = "Preview Image";
-"Common.Controls.Keyboard.Timeline.PreviousStatus" = "Previous Status";
-"Common.Controls.Keyboard.Timeline.ReplyStatus" = "Reply Status";
+"Common.Controls.Keyboard.Timeline.PreviousStatus" = "Previous Post";
+"Common.Controls.Keyboard.Timeline.ReplyStatus" = "Reply to Post";
"Common.Controls.Keyboard.Timeline.ToggleContentWarning" = "Toggle Content Warning";
-"Common.Controls.Keyboard.Timeline.ToggleFavorite" = "Toggle Status Favorite";
-"Common.Controls.Keyboard.Timeline.ToggleReblog" = "Toggle Status Reblog";
+"Common.Controls.Keyboard.Timeline.ToggleFavorite" = "Toggle Favorite on Post";
+"Common.Controls.Keyboard.Timeline.ToggleReblog" = "Toggle Reblog on Post";
"Common.Controls.Status.Actions.Favorite" = "Favorite";
"Common.Controls.Status.Actions.Menu" = "Menu";
"Common.Controls.Status.Actions.Reblog" = "Reblog";
"Common.Controls.Status.Actions.Reply" = "Reply";
"Common.Controls.Status.Actions.Unfavorite" = "Unfavorite";
-"Common.Controls.Status.Actions.Unreblog" = "Unreblog";
+"Common.Controls.Status.Actions.Unreblog" = "Undo reblog";
"Common.Controls.Status.ContentWarning" = "Content Warning";
"Common.Controls.Status.MediaContentWarning" = "Tap anywhere to reveal";
"Common.Controls.Status.Poll.Closed" = "Closed";
@@ -120,44 +120,44 @@ Please check your internet connection.";
"Common.Controls.Timeline.Accessibility.CountReblogs" = "%@ reblogs";
"Common.Controls.Timeline.Accessibility.CountReplies" = "%@ replies";
"Common.Controls.Timeline.Filtered" = "Filtered";
-"Common.Controls.Timeline.Header.BlockedWarning" = "You can’t view this’s profile
+"Common.Controls.Timeline.Header.BlockedWarning" = "You can’t view this user’s profile
until they unblock you.";
-"Common.Controls.Timeline.Header.BlockingWarning" = "You can’t view this profile
+"Common.Controls.Timeline.Header.BlockingWarning" = "You can’t view this user's profile
until you unblock them.
-Your account looks like this to them.";
-"Common.Controls.Timeline.Header.NoStatusFound" = "No Status Found";
-"Common.Controls.Timeline.Header.SuspendedWarning" = "This account has been suspended.";
+Your profile looks like this to them.";
+"Common.Controls.Timeline.Header.NoStatusFound" = "No Post Found";
+"Common.Controls.Timeline.Header.SuspendedWarning" = "This user has been suspended.";
"Common.Controls.Timeline.Header.UserBlockedWarning" = "You can’t view %@’s profile
until they unblock you.";
"Common.Controls.Timeline.Header.UserBlockingWarning" = "You can’t view %@’s profile
until you unblock them.
-Your account looks like this to them.";
+Your profile looks like this to them.";
"Common.Controls.Timeline.Header.UserSuspendedWarning" = "%@’s account has been suspended.";
"Common.Controls.Timeline.Loader.LoadMissingPosts" = "Load missing posts";
"Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts...";
"Common.Controls.Timeline.Loader.ShowMoreReplies" = "Show more replies";
"Common.Controls.Timeline.Timestamp.Now" = "Now";
"Common.Controls.Timeline.Timestamp.TimeAgo" = "%@ ago";
-"Scene.Compose.Accessibility.AppendAttachment" = "Append attachment";
-"Scene.Compose.Accessibility.AppendPoll" = "Append poll";
-"Scene.Compose.Accessibility.CustomEmojiPicker" = "Custom emoji picker";
-"Scene.Compose.Accessibility.DisableContentWarning" = "Disable content warning";
-"Scene.Compose.Accessibility.EnableContentWarning" = "Enable content warning";
+"Scene.Compose.Accessibility.AppendAttachment" = "Add Attachment";
+"Scene.Compose.Accessibility.AppendPoll" = "Add Poll";
+"Scene.Compose.Accessibility.CustomEmojiPicker" = "Custom Emoji Picker";
+"Scene.Compose.Accessibility.DisableContentWarning" = "Disable Content Warning";
+"Scene.Compose.Accessibility.EnableContentWarning" = "Enable Content Warning";
"Scene.Compose.Accessibility.InputLimitExceedsCount" = "Input limit exceeds %ld";
"Scene.Compose.Accessibility.InputLimitRemainsCount" = "Input limit remains %ld";
-"Scene.Compose.Accessibility.PostVisibilityMenu" = "Post visibility menu";
-"Scene.Compose.Accessibility.RemovePoll" = "Remove poll";
+"Scene.Compose.Accessibility.PostVisibilityMenu" = "Post Visibility Menu";
+"Scene.Compose.Accessibility.RemovePoll" = "Remove Poll";
"Scene.Compose.Attachment.AttachmentBroken" = "This %@ is broken and can’t be
uploaded to Mastodon.";
-"Scene.Compose.Attachment.DescriptionPhoto" = "Describe photo for low vision people...";
-"Scene.Compose.Attachment.DescriptionVideo" = "Describe what’s happening for low vision people...";
+"Scene.Compose.Attachment.DescriptionPhoto" = "Describe the photo for the visually-impaired...";
+"Scene.Compose.Attachment.DescriptionVideo" = "Describe the video for the visually-impaired...";
"Scene.Compose.Attachment.Photo" = "photo";
"Scene.Compose.Attachment.Video" = "video";
"Scene.Compose.AutoComplete.SpaceToAdd" = "Space to add";
"Scene.Compose.ComposeAction" = "Publish";
"Scene.Compose.ContentInputPlaceholder" = "Type or paste what’s on your mind";
"Scene.Compose.ContentWarning.Placeholder" = "Write an accurate warning here...";
-"Scene.Compose.Keyboard.AppendAttachmentEntry" = "Append Attachment - %@";
+"Scene.Compose.Keyboard.AppendAttachmentEntry" = "Add Attachment - %@";
"Scene.Compose.Keyboard.DiscardPost" = "Discard Post";
"Scene.Compose.Keyboard.PublishPost" = "Publish Post";
"Scene.Compose.Keyboard.SelectVisibilityEntry" = "Select Visibility - %@";
@@ -202,10 +202,10 @@ tap the link to confirm your account.";
"Scene.HomeTimeline.Title" = "Home";
"Scene.Notification.Action.Favourite" = "favorited your post";
"Scene.Notification.Action.Follow" = "followed you";
-"Scene.Notification.Action.FollowRequest" = "request to follow you";
+"Scene.Notification.Action.FollowRequest" = "requested to follow you";
"Scene.Notification.Action.Mention" = "mentioned you";
"Scene.Notification.Action.Poll" = "Your poll has ended";
-"Scene.Notification.Action.Reblog" = "rebloged your post";
+"Scene.Notification.Action.Reblog" = "reblogged your post";
"Scene.Notification.Keyobard.ShowEverything" = "Show Everything";
"Scene.Notification.Keyobard.ShowMentions" = "Show Mentions";
"Scene.Notification.Title.Everything" = "Everything";
@@ -222,9 +222,9 @@ tap the link to confirm your account.";
"Scene.Profile.Fields.AddRow" = "Add Row";
"Scene.Profile.Fields.Placeholder.Content" = "Content";
"Scene.Profile.Fields.Placeholder.Label" = "Label";
-"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message" = "Confirm unblock %@";
+"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message" = "Confirm to unblock %@";
"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title" = "Unblock Account";
-"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Confirm unmute %@";
+"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Confirm to unmute %@";
"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Title" = "Unmute Account";
"Scene.Profile.SegmentedControl.Media" = "Media";
"Scene.Profile.SegmentedControl.Posts" = "Posts";
@@ -238,7 +238,7 @@ tap the link to confirm your account.";
"Scene.Register.Error.Item.Username" = "Username";
"Scene.Register.Error.Reason.Accepted" = "%@ must be accepted";
"Scene.Register.Error.Reason.Blank" = "%@ is required";
-"Scene.Register.Error.Reason.Blocked" = "%@ contains a disallowed e-mail provider";
+"Scene.Register.Error.Reason.Blocked" = "%@ contains a disallowed email provider";
"Scene.Register.Error.Reason.Inclusion" = "%@ is not a supported value";
"Scene.Register.Error.Reason.Invalid" = "%@ is invalid";
"Scene.Register.Error.Reason.Reserved" = "%@ is a reserved keyword";
@@ -246,7 +246,7 @@ tap the link to confirm your account.";
"Scene.Register.Error.Reason.TooLong" = "%@ is too long";
"Scene.Register.Error.Reason.TooShort" = "%@ is too short";
"Scene.Register.Error.Reason.Unreachable" = "%@ does not seem to exist";
-"Scene.Register.Error.Special.EmailInvalid" = "This is not a valid e-mail address";
+"Scene.Register.Error.Special.EmailInvalid" = "This is not a valid email address";
"Scene.Register.Error.Special.PasswordTooShort" = "Password is too short (must be at least 8 characters)";
"Scene.Register.Error.Special.UsernameInvalid" = "Username must only contain alphanumeric characters and underscores";
"Scene.Register.Error.Special.UsernameTooLong" = "Username is too long (can’t be longer than 30 characters)";
@@ -271,16 +271,19 @@ tap the link to confirm your account.";
"Scene.Search.Recommend.Accounts.Follow" = "Follow";
"Scene.Search.Recommend.Accounts.Title" = "Accounts you might like";
"Scene.Search.Recommend.ButtonText" = "See All";
-"Scene.Search.Recommend.HashTag.Description" = "Hashtags that are getting quite a bit of attention among people you follow";
+"Scene.Search.Recommend.HashTag.Description" = "Hashtags that are getting quite a bit of attention";
"Scene.Search.Recommend.HashTag.PeopleTalking" = "%@ people are talking";
-"Scene.Search.Recommend.HashTag.Title" = "Trending in your timeline";
-"Scene.Search.Searchbar.Cancel" = "Cancel";
-"Scene.Search.Searchbar.Placeholder" = "Search hashtags and users";
+"Scene.Search.Recommend.HashTag.Title" = "Trending on Mastodon";
+"Scene.Search.SearchBar.Cancel" = "Cancel";
+"Scene.Search.SearchBar.Placeholder" = "Search hashtags and users";
"Scene.Search.Searching.Clear" = "Clear";
+"Scene.Search.Searching.EmptyState.NoResults" = "No results";
"Scene.Search.Searching.RecentSearch" = "Recent searches";
"Scene.Search.Searching.Segment.All" = "All";
"Scene.Search.Searching.Segment.Hashtags" = "Hashtags";
"Scene.Search.Searching.Segment.People" = "People";
+"Scene.Search.Searching.Segment.Posts" = "Posts";
+"Scene.Search.Title" = "Search";
"Scene.ServerPicker.Button.Category.Academia" = "academia";
"Scene.ServerPicker.Button.Category.Activism" = "activism";
"Scene.ServerPicker.Button.Category.All" = "All";
@@ -297,7 +300,7 @@ tap the link to confirm your account.";
"Scene.ServerPicker.Button.Category.Tech" = "tech";
"Scene.ServerPicker.Button.SeeLess" = "See Less";
"Scene.ServerPicker.Button.SeeMore" = "See More";
-"Scene.ServerPicker.EmptyState.BadNetwork" = "Something went wrong while loading data. Check your internet connection.";
+"Scene.ServerPicker.EmptyState.BadNetwork" = "Something went wrong while loading the data. Check your internet connection.";
"Scene.ServerPicker.EmptyState.FindingServers" = "Finding available servers...";
"Scene.ServerPicker.EmptyState.NoResults" = "No results";
"Scene.ServerPicker.Input.Placeholder" = "Find a server or join your own...";
@@ -318,11 +321,12 @@ any server.";
"Scene.Settings.Section.Appearance.Dark" = "Always Dark";
"Scene.Settings.Section.Appearance.Light" = "Always Light";
"Scene.Settings.Section.Appearance.Title" = "Appearance";
-"Scene.Settings.Section.AppearanceSettings.DisableAvatarAnimation" = "Disable avatar animation";
-"Scene.Settings.Section.AppearanceSettings.TrueBlackDarkMode" = "True black Dark Mode";
-"Scene.Settings.Section.Boringzone.Privacy" = "Privacy Policy";
-"Scene.Settings.Section.Boringzone.Terms" = "Terms of Service";
-"Scene.Settings.Section.Boringzone.Title" = "The Boring zone";
+"Scene.Settings.Section.AppearanceSettings.DisableAvatarAnimation" = "Disable animated avatars";
+"Scene.Settings.Section.AppearanceSettings.TrueBlackDarkMode" = "True black dark mode";
+"Scene.Settings.Section.BoringZone.AccountSettings" = "Account settings";
+"Scene.Settings.Section.BoringZone.Privacy" = "Privacy Policy";
+"Scene.Settings.Section.BoringZone.Terms" = "Terms of Service";
+"Scene.Settings.Section.BoringZone.Title" = "The Boring Zone";
"Scene.Settings.Section.Notifications.Boosts" = "Reblogs my post";
"Scene.Settings.Section.Notifications.Favorites" = "Favorites my post";
"Scene.Settings.Section.Notifications.Follows" = "Follows me";
@@ -333,11 +337,11 @@ any server.";
"Scene.Settings.Section.Notifications.Trigger.Follower" = "a follower";
"Scene.Settings.Section.Notifications.Trigger.Noone" = "no one";
"Scene.Settings.Section.Notifications.Trigger.Title" = "Notify me when";
-"Scene.Settings.Section.Preference.Title" = "Preference";
-"Scene.Settings.Section.Preference.UsingDefaultBrowser" = "Using default browser open link";
-"Scene.Settings.Section.Spicyzone.Clear" = "Clear Media Cache";
-"Scene.Settings.Section.Spicyzone.Signout" = "Sign Out";
-"Scene.Settings.Section.Spicyzone.Title" = "The spicy zone";
+"Scene.Settings.Section.Preference.Title" = "Preferences";
+"Scene.Settings.Section.Preference.UsingDefaultBrowser" = "Use default browser to open links";
+"Scene.Settings.Section.SpicyZone.Clear" = "Clear Media Cache";
+"Scene.Settings.Section.SpicyZone.Signout" = "Sign Out";
+"Scene.Settings.Section.SpicyZone.Title" = "The Spicy Zone";
"Scene.Settings.Title" = "Settings";
"Scene.SuggestionAccount.FollowExplain" = "When you follow someone, you’ll see their posts in your home feed.";
"Scene.SuggestionAccount.Title" = "Find People to Follow";
diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift
index f399907b3..ca0b3c44e 100644
--- a/Mastodon/Scene/Compose/ComposeViewController.swift
+++ b/Mastodon/Scene/Compose/ComposeViewController.swift
@@ -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
}
diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+Provider.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+Provider.swift
index 3bc3a36b5..d7beaca6f 100644
--- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+Provider.swift
+++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+Provider.swift
@@ -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
+ }
}
diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+Provider.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+Provider.swift
index d735d5843..83022f5d7 100644
--- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+Provider.swift
+++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+Provider.swift
@@ -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
+ }
}
diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift
index a51fa6d78..73650b6ea 100644
--- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift
+++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift
@@ -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
diff --git a/Mastodon/Scene/MainTab/MainTabBarController.swift b/Mastodon/Scene/MainTab/MainTabBarController.swift
index 397589fd5..5a85e872e 100644
--- a/Mastodon/Scene/MainTab/MainTabBarController.swift
+++ b/Mastodon/Scene/MainTab/MainTabBarController.swift
@@ -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
}
}
diff --git a/Mastodon/Scene/Notification/NotificationViewController+StatusProvider.swift b/Mastodon/Scene/Notification/NotificationViewController+StatusProvider.swift
index 486d18832..127cca1b9 100644
--- a/Mastodon/Scene/Notification/NotificationViewController+StatusProvider.swift
+++ b/Mastodon/Scene/Notification/NotificationViewController+StatusProvider.swift
@@ -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
+ }
}
diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewController+Provider.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewController+Provider.swift
index 88f368c15..f4631b6e6 100644
--- a/Mastodon/Scene/Profile/Favorite/FavoriteViewController+Provider.swift
+++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewController+Provider.swift
@@ -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
+ }
}
diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+Provider.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+Provider.swift
index 30029ae5b..8c46f0ad6 100644
--- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+Provider.swift
+++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+Provider.swift
@@ -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
+ }
}
diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift
index be06d781a..2566006e0 100644
--- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift
+++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift
@@ -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
}
}
}
diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift
index 7d6a6c8fe..42edafb0f 100644
--- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift
+++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift
@@ -24,11 +24,12 @@ final class UserTimelineViewModel {
let queryFilter: CurrentValueSubject
let statusFetchedResultsController: StatusFetchedResultsController
var cellFrameCache = NSCache()
-
+
let isBlocking = CurrentValueSubject(false)
let isBlockedBy = CurrentValueSubject(false)
let isSuspended = CurrentValueSubject(false)
let userDisplayName = CurrentValueSubject(nil) // for suspended prompt label
+ var dataSourceDidUpdate = PassthroughSubject()
// output
var diffableDataSource: UITableViewDiffableDataSource?
@@ -77,9 +78,13 @@ final class UserTimelineViewModel {
var snapshot = NSDiffableDataSourceSnapshot()
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
diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+Provider.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+Provider.swift
index 96963914c..dd7730630 100644
--- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+Provider.swift
+++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+Provider.swift
@@ -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
+ }
}
diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift b/Mastodon/Scene/Search/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift
similarity index 100%
rename from Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift
rename to Mastodon/Scene/Search/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift
diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift b/Mastodon/Scene/Search/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift
similarity index 100%
rename from Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift
rename to Mastodon/Scene/Search/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift
diff --git a/Mastodon/Scene/Search/SearchViewController+Follow.swift b/Mastodon/Scene/Search/Search/SearchViewController+Follow.swift
similarity index 100%
rename from Mastodon/Scene/Search/SearchViewController+Follow.swift
rename to Mastodon/Scene/Search/Search/SearchViewController+Follow.swift
diff --git a/Mastodon/Scene/Search/SearchViewController+Recommend.swift b/Mastodon/Scene/Search/Search/SearchViewController+Recommend.swift
similarity index 86%
rename from Mastodon/Scene/Search/SearchViewController+Recommend.swift
rename to Mastodon/Scene/Search/Search/SearchViewController+Recommend.swift
index 3425ac193..e0b02e41c 100644
--- a/Mastodon/Scene/Search/SearchViewController+Recommend.swift
+++ b/Mastodon/Scene/Search/Search/SearchViewController+Recommend.swift
@@ -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
}
diff --git a/Mastodon/Scene/Search/Search/SearchViewController.swift b/Mastodon/Scene/Search/Search/SearchViewController.swift
new file mode 100644
index 000000000..2880a1504
--- /dev/null
+++ b/Mastodon/Scene/Search/Search/SearchViewController.swift
@@ -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()
+ 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
diff --git a/Mastodon/Scene/Search/Search/SearchViewModel.swift b/Mastodon/Scene/Search/Search/SearchViewModel.swift
new file mode 100644
index 000000000..681fa0f54
--- /dev/null
+++ b/Mastodon/Scene/Search/Search/SearchViewModel.swift
@@ -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()
+
+ // input
+ let context: AppContext
+ weak var coordinator: SceneCoordinator!
+
+ let currentMastodonUser = CurrentValueSubject(nil)
+ let viewDidAppeared = PassthroughSubject()
+
+ // output
+
+ // var recommendHashTags = [Mastodon.Entity.Tag]()
+ var recommendAccounts = [NSManagedObjectID]()
+ var recommendAccountsFallback = PassthroughSubject()
+
+ var hashtagDiffableDataSource: UICollectionViewDiffableDataSource?
+ var accountDiffableDataSource: UICollectionViewDiffableDataSource?
+
+ 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, Error> { response } }
+ .catch { error in Just(Result, 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()
+ 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, 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, 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()
+ snapshot.appendSections([.main])
+ snapshot.appendItems(self.recommendAccounts, toSection: .main)
+ dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
+ }
+
+}
diff --git a/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift b/Mastodon/Scene/Search/Search/View/SearchRecommendCollectionHeader.swift
similarity index 100%
rename from Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift
rename to Mastodon/Scene/Search/Search/View/SearchRecommendCollectionHeader.swift
diff --git a/Mastodon/Scene/Search/SearchDetail/SearchDetailViewController.swift b/Mastodon/Scene/Search/SearchDetail/SearchDetailViewController.swift
new file mode 100644
index 000000000..1debc188c
--- /dev/null
+++ b/Mastodon/Scene/Search/SearchDetail/SearchDetailViewController.swift
@@ -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()
+ var observations = Set()
+
+ 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
+ }
+}
diff --git a/Mastodon/Scene/Search/SearchDetail/SearchDetailViewModel.swift b/Mastodon/Scene/Search/SearchDetail/SearchDetailViewModel.swift
new file mode 100644
index 000000000..e53108bc5
--- /dev/null
+++ b/Mastodon/Scene/Search/SearchDetail/SearchDetailViewModel.swift
@@ -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()
+ let navigationBarFrame = CurrentValueSubject(.zero)
+
+ // output
+ let searchScopes = SearchScope.allCases
+ let selectedSearchScope = CurrentValueSubject(.all)
+ let searchText: CurrentValueSubject
+ let searchActionPublisher = PassthroughSubject()
+
+ 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
+ }
+ }
+ }
+}
diff --git a/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewController.swift b/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewController.swift
new file mode 100644
index 000000000..f60b2029d
--- /dev/null
+++ b/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewController.swift
@@ -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()
+
+ 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()
+ }
+}
diff --git a/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewModel.swift b/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewModel.swift
new file mode 100644
index 000000000..19bb875c9
--- /dev/null
+++ b/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewModel.swift
@@ -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()
+
+ // input
+ let context: AppContext
+ let searchHistoryFetchedResultController: SearchHistoryFetchedResultController
+
+ // output
+ var diffableDataSource: UITableViewDiffableDataSource!
+
+ 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()
+ 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()
+ 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)
+
+ }
+}
diff --git a/Mastodon/Scene/Search/SearchDetail/SearchHistory/View/SearchHistoryTableHeaderView.swift b/Mastodon/Scene/Search/SearchDetail/SearchHistory/View/SearchHistoryTableHeaderView.swift
new file mode 100644
index 000000000..6a360e78b
--- /dev/null
+++ b/Mastodon/Scene/Search/SearchDetail/SearchHistory/View/SearchHistoryTableHeaderView.swift
@@ -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()
+
+ 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
+ }
+}
diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController+StatusProvider.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController+StatusProvider.swift
new file mode 100644
index 000000000..73e3ffb82
--- /dev/null
+++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController+StatusProvider.swift
@@ -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 {
+ return Future { promise in promise(.success(nil)) }
+ }
+
+ func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future {
+ 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 {
+ return Future { promise in promise(.success(nil)) }
+ }
+
+ var managedObjectContext: NSManagedObjectContext {
+ return self.viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext
+ }
+
+ var tableViewDiffableDataSource: UITableViewDiffableDataSource? {
+ 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 {}
diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController.swift
new file mode 100644
index 000000000..6c320af51
--- /dev/null
+++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController.swift
@@ -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()
+
+ 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 {
+ 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)
+ }
+}
diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+State.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+State.swift
new file mode 100644
index 000000000..0445ee017
--- /dev/null
+++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+State.swift
@@ -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
+ }
+ }
+ }
+}
diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel.swift
new file mode 100644
index 000000000..0ace96226
--- /dev/null
+++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel.swift
@@ -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()
+
+ // input
+ let context: AppContext
+ let searchScope: SearchDetailViewModel.SearchScope
+ let searchText = CurrentValueSubject("")
+ let statusFetchedResultsController: StatusFetchedResultsController
+ let viewDidAppear = CurrentValueSubject(false)
+ var cellFrameCache = NSCache()
+ var navigationBarFrame = CurrentValueSubject(.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!
+ let didDataSourceUpdate = PassthroughSubject()
+
+ 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()
+ 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()
+ 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
+ }
+ }
+}
diff --git a/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift b/Mastodon/Scene/Search/SearchDetail/TableViewCell/SearchResultTableViewCell.swift
similarity index 65%
rename from Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift
rename to Mastodon/Scene/Search/SearchDetail/TableViewCell/SearchResultTableViewCell.swift
index 6a40f7902..8223c6ddf 100644
--- a/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift
+++ b/Mastodon/Scene/Search/SearchDetail/TableViewCell/SearchResultTableViewCell.swift
@@ -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"
diff --git a/Mastodon/Scene/Search/SearchViewController+Searching.swift b/Mastodon/Scene/Search/SearchViewController+Searching.swift
deleted file mode 100644
index 8319781c4..000000000
--- a/Mastodon/Scene/Search/SearchViewController+Searching.swift
+++ /dev/null
@@ -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)
- }
-}
diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift
deleted file mode 100644
index 89b71f499..000000000
--- a/Mastodon/Scene/Search/SearchViewController.swift
+++ /dev/null
@@ -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()
- 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
diff --git a/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift b/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift
deleted file mode 100644
index 4fe68e47d..000000000
--- a/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift
+++ /dev/null
@@ -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)
- }
- }
- }
-}
diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift
deleted file mode 100644
index fd7a8c813..000000000
--- a/Mastodon/Scene/Search/SearchViewModel.swift
+++ /dev/null
@@ -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()
-
- // input
- let context: AppContext
- weak var coordinator: SceneCoordinator!
-
- let currentMastodonUser = CurrentValueSubject(nil)
- let viewDidAppeared = PassthroughSubject()
-
- // output
- let searchText = CurrentValueSubject("")
- let searchScope = CurrentValueSubject(Mastodon.API.V2.Search.SearchType.default)
-
- let isSearching = CurrentValueSubject(false)
-
- let searchResult = CurrentValueSubject(nil)
-
- // var recommendHashTags = [Mastodon.Entity.Tag]()
- var recommendAccounts = [NSManagedObjectID]()
- var recommendAccountsFallback = PassthroughSubject()
-
- var hashtagDiffableDataSource: UICollectionViewDiffableDataSource?
- var accountDiffableDataSource: UICollectionViewDiffableDataSource?
- var searchResultDiffableDataSource: UITableViewDiffableDataSource?
-
- 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(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, 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, Error> { response } }
- .catch { error in Just(Result, 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()
- 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, Error> { response } }
- .catch { error in Just(Result, 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()
- 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, 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, 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()
- 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()
- 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
- }
- }
-}
diff --git a/Mastodon/Scene/Settings/SettingsViewController.swift b/Mastodon/Scene/Settings/SettingsViewController.swift
index fce06caf3..a295272ee 100644
--- a/Mastodon/Scene/Settings/SettingsViewController.swift
+++ b/Mastodon/Scene/Settings/SettingsViewController.swift
@@ -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 {
diff --git a/Mastodon/Scene/Settings/SettingsViewModel.swift b/Mastodon/Scene/Settings/SettingsViewModel.swift
index 5d60f1669..2d7fafde3 100644
--- a/Mastodon/Scene/Settings/SettingsViewModel.swift
+++ b/Mastodon/Scene/Settings/SettingsViewModel.swift
@@ -12,13 +12,15 @@ import Foundation
import MastodonSDK
import UIKit
import os.log
+import AuthenticationServices
class SettingsViewModel {
var disposeBag = Set()
let context: AppContext
-
+ var mastodonAuthenticationController: MastodonAuthenticationController?
+
// input
let setting: CurrentValueSubject
var updateDisposeBag = Set()
@@ -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:
diff --git a/Mastodon/Scene/Share/NavigationController/AdaptiveStatusBarStyleNavigationController.swift b/Mastodon/Scene/Share/NavigationController/AdaptiveStatusBarStyleNavigationController.swift
index 5f4302712..eb260853c 100644
--- a/Mastodon/Scene/Share/NavigationController/AdaptiveStatusBarStyleNavigationController.swift
+++ b/Mastodon/Scene/Share/NavigationController/AdaptiveStatusBarStyleNavigationController.swift
@@ -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)
- }
- }
}
diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineBottomLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineBottomLoaderTableViewCell.swift
index 8d3589fb1..70f366bce 100644
--- a/Mastodon/Scene/Share/View/TableviewCell/TimelineBottomLoaderTableViewCell.swift
+++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineBottomLoaderTableViewCell.swift
@@ -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()
}
}
diff --git a/Mastodon/Scene/Thread/ThreadViewController+Provider.swift b/Mastodon/Scene/Thread/ThreadViewController+Provider.swift
index a76a22d0b..c6bd29e15 100644
--- a/Mastodon/Scene/Thread/ThreadViewController+Provider.swift
+++ b/Mastodon/Scene/Thread/ThreadViewController+Provider.swift
@@ -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
+ }
}
diff --git a/Mastodon/Scene/Transition/Search/SearchToSearchDetailViewControllerAnimatedTransitioning.swift b/Mastodon/Scene/Transition/Search/SearchToSearchDetailViewControllerAnimatedTransitioning.swift
new file mode 100644
index 000000000..f06d04d9a
--- /dev/null
+++ b/Mastodon/Scene/Transition/Search/SearchToSearchDetailViewControllerAnimatedTransitioning.swift
@@ -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
+ }
+}
diff --git a/Mastodon/Scene/Transition/Search/SearchTransitionController.swift b/Mastodon/Scene/Transition/Search/SearchTransitionController.swift
new file mode 100644
index 000000000..871fd7baf
--- /dev/null
+++ b/Mastodon/Scene/Transition/Search/SearchTransitionController.swift
@@ -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
+ }
+ }
+}
diff --git a/Mastodon/Scene/Transition/ViewControllerAnimatedTransitioning.swift b/Mastodon/Scene/Transition/ViewControllerAnimatedTransitioning.swift
index 078bf6565..71fd199f4 100644
--- a/Mastodon/Scene/Transition/ViewControllerAnimatedTransitioning.swift
+++ b/Mastodon/Scene/Transition/ViewControllerAnimatedTransitioning.swift
@@ -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) {
diff --git a/Mastodon/Service/APIService/APIService+Media.swift b/Mastodon/Service/APIService/APIService+Media.swift
index 1fffb5a6c..7c1fd64d9 100644
--- a/Mastodon/Service/APIService/APIService+Media.swift
+++ b/Mastodon/Service/APIService/APIService+Media.swift
@@ -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, 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, Error> {
+ let authorization = mastodonAuthenticationBox.userAuthorization
+
+ return Mastodon.API.V2.Media.uploadMedia(
+ session: session,
+ domain: domain,
+ query: query,
+ authorization: authorization
+ )
+ .eraseToAnyPublisher()
+ }
func updateMedia(
domain: String,
diff --git a/Mastodon/Service/APIService/APIService+Search.swift b/Mastodon/Service/APIService/APIService+Search.swift
index 986e3d931..e1642fb5e 100644
--- a/Mastodon/Service/APIService/APIService+Search.swift
+++ b/Mastodon/Service/APIService/APIService+Search.swift
@@ -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, 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, 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 in
+ switch result {
+ case .success:
+ return response
+ case .failure(let error):
+ throw error
+ }
+ }
+ .eraseToAnyPublisher()
+ }
+ .eraseToAnyPublisher()
}
}
diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift
index 32628a203..201e400d1 100644
--- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift
+++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift
@@ -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 }
diff --git a/Mastodon/Service/AuthenticationService.swift b/Mastodon/Service/AuthenticationService.swift
index b72f281fe..a0bbca57a 100644
--- a/Mastodon/Service/AuthenticationService.swift
+++ b/Mastodon/Service/AuthenticationService.swift
@@ -27,7 +27,6 @@ final class AuthenticationService: NSObject {
let mastodonAuthenticationBoxes = CurrentValueSubject<[AuthenticationService.MastodonAuthenticationBox], Never>([])
let activeMastodonAuthentication = CurrentValueSubject(nil)
let activeMastodonAuthenticationBox = CurrentValueSubject(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()
-
- filterUpdateTimerPublisher
- .map { _ in }
- .subscribe(filterUpdatePublisher)
- .store(in: &disposeBag)
-
- Publishers.CombineLatest(
- activeMastodonAuthenticationBox,
- filterUpdatePublisher
- )
- .flatMap { box, _ -> AnyPublisher, 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, Error>.success(newResponse)
- }
- .catch { error in
- Just(Result, 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()
}
}
diff --git a/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService+UploadState.swift b/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService+UploadState.swift
index f773115de..7976156fd 100644
--- a/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService+UploadState.swift
+++ b/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService+UploadState.swift
@@ -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 ?? "")
service.attachment.value = response.value
stateMachine.enter(Finish.self)
}
.store(in: &service.disposeBag)
}
+
}
class Fail: MastodonAttachmentService.UploadState {
diff --git a/Mastodon/Service/StatusFilterService.swift b/Mastodon/Service/StatusFilterService.swift
new file mode 100644
index 000000000..38a1a17c4
--- /dev/null
+++ b/Mastodon/Service/StatusFilterService.swift
@@ -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()
+
+ // input
+ weak var apiService: APIService?
+ weak var authenticationService: AuthenticationService?
+ let filterUpdatePublisher = PassthroughSubject()
+
+ // 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, 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, Error>.success(newResponse)
+ }
+ .catch { error in
+ Just(Result, 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()
+ }
+
+}
diff --git a/Mastodon/Service/StatusPrefetchingService.swift b/Mastodon/Service/StatusPrefetchingService.swift
index 7828c5cf7..34b1d7a01 100644
--- a/Mastodon/Service/StatusPrefetchingService.swift
+++ b/Mastodon/Service/StatusPrefetchingService.swift
@@ -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()
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(
diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift
index f1de6dd7a..7db669e5e 100644
--- a/Mastodon/State/AppContext.swift
+++ b/Mastodon/State/AppContext.swift
@@ -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
diff --git a/Mastodon/Supporting Files/SceneDelegate.swift b/Mastodon/Supporting Files/SceneDelegate.swift
index 695f2df80..6964eef91 100644
--- a/Mastodon/Supporting Files/SceneDelegate.swift
+++ b/Mastodon/Supporting Files/SceneDelegate.swift
@@ -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) {
diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Media.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Media.swift
index a292df6ae..d05cac01a 100644
--- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Media.swift
+++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Media.swift
@@ -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)
+ }
}
+
}
diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Media.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Media.swift
new file mode 100644
index 000000000..4f8ac71d5
--- /dev/null
+++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Media.swift
@@ -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, 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()
+ }
+}
diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Search.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Search.swift
index c0a687f17..d9a0aa60d 100644
--- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Search.swift
+++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Search.swift
@@ -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
diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift
index e202568c5..f72fd3aff 100644
--- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift
+++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift
@@ -123,6 +123,7 @@ extension Mastodon.API {
extension Mastodon.API.V2 {
public enum Search { }
public enum Suggestions { }
+ public enum Media { }
}
extension Mastodon.API {
diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Attachment.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Attachment.swift
index d50d87695..b5daf278e 100644
--- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Attachment.swift
+++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Attachment.swift
@@ -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?