From a9cce7b3e36fad19d851552817d6243a242dce69 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 21 Jun 2021 16:37:28 +0800 Subject: [PATCH 1/8] fix: delete relationship missing for Status issue --- Mastodon/Service/APIService/APIService+Status.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Mastodon/Service/APIService/APIService+Status.swift b/Mastodon/Service/APIService/APIService+Status.swift index c927b05a8..01bc667ef 100644 --- a/Mastodon/Service/APIService/APIService+Status.swift +++ b/Mastodon/Service/APIService/APIService+Status.swift @@ -117,6 +117,14 @@ extension APIService { } }() if let status = oldStatus { + let homeTimelineIndexes = status.homeTimelineIndexes ?? Set() + for homeTimelineIndex in homeTimelineIndexes { + self.backgroundManagedObjectContext.delete(homeTimelineIndex) + } + let inNotifications = status.inNotifications ?? Set() + for notification in inNotifications { + self.backgroundManagedObjectContext.delete(notification) + } self.backgroundManagedObjectContext.delete(status) } } From 2dfd6168a96f6b08fe585a0d322954f0d578aac5 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 21 Jun 2021 16:38:59 +0800 Subject: [PATCH 2/8] fix: handle status delete UI updater in thread scene --- .../xcschemes/xcschememanagement.plist | 4 +- .../Diffiable/Section/StatusSection.swift | 21 +++-- .../Thread/ThreadViewModel+Diffable.swift | 85 ++++++++++++++++--- Mastodon/Scene/Thread/ThreadViewModel.swift | 41 ++++++++- 4 files changed, 125 insertions(+), 26 deletions(-) diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 2fb2d9806..73b68ec90 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 - 17 + 16 Mastodon - RTL.xcscheme_^#shared#^_ @@ -32,7 +32,7 @@ NotificationService.xcscheme_^#shared#^_ orderHint - 16 + 17 SuppressBuildableAutocreation diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index c5d0eb19b..8a3df09b1 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -47,14 +47,18 @@ extension StatusSection { // configure cell managedObjectContext.performAndWait { - let timelineIndex = managedObjectContext.object(with: objectID) as! HomeTimelineIndex + let timelineIndex = managedObjectContext.object(with: objectID) as? HomeTimelineIndex + // note: force check optional for status + // status maybe here when delete in thread scene + guard let status = timelineIndex?.status, + let userID = timelineIndex?.userID else { return } StatusSection.configure( cell: cell, dependency: dependency, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, - status: timelineIndex.status, - requestUserID: timelineIndex.userID, + status: status, + requestUserID: userID, statusItemAttribute: attribute ) } @@ -752,12 +756,13 @@ extension StatusSection { return L10n.Common.Controls.Timeline.Accessibility.countReblogs(status.favouritesCount.intValue) }() Publishers.CombineLatest( - dependency.context.blockDomainService.blockedDomains, + dependency.context.blockDomainService.blockedDomains.setFailureType(to: ManagedObjectObserver.Error.self), ManagedObjectObserver.observe(object: status.authorForUserProvider) - .assertNoFailure() - ) + ) .receive(on: RunLoop.main) - .sink { [weak dependency, weak cell] _, change in + .sink(receiveCompletion: { _ in + // do nothing + }, receiveValue: { [weak dependency, weak cell] _, change in guard let cell = cell else { return } guard let dependency = dependency else { return } switch change.changeType { @@ -769,7 +774,7 @@ extension StatusSection { break } StatusSection.setupStatusMoreButtonMenu(cell: cell, dependency: dependency, status: status) - } + }) .store(in: &cell.disposeBag) self.setupStatusMoreButtonMenu(cell: cell, dependency: dependency, status: status) } diff --git a/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift b/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift index 323a7a545..58e618f89 100644 --- a/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift +++ b/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift @@ -8,6 +8,8 @@ import UIKit import Combine import CoreData +import CoreDataStack +import MastodonSDK extension ThreadViewModel { @@ -41,13 +43,29 @@ extension ThreadViewModel { diffableDataSource?.apply(snapshot, animatingDifferences: false, completion: nil) Publishers.CombineLatest3( + rootItem.removeDuplicates(), + ancestorItems.removeDuplicates(), + descendantItems.removeDuplicates() + ) + .receive(on: RunLoop.main) + .sink { [weak self] rootItem, ancestorItems, descendantItems in + guard let self = self else { return } + var items: [Item] = [] + rootItem.flatMap { items.append($0) } + items.append(contentsOf: ancestorItems) + items.append(contentsOf: descendantItems) + self.updateDeletedStatus(for: items) + } + .store(in: &disposeBag) + + Publishers.CombineLatest4( rootItem, ancestorItems, - descendantItems + descendantItems, + existStatusFetchedResultsController.objectIDs ) - .debounce(for: .milliseconds(100), scheduler: DispatchQueue.main) // some magic to avoid jitter - .receive(on: DispatchQueue.main) - .sink { [weak self] rootItem, ancestorItems, descendantItems in + .debounce(for: .milliseconds(100), scheduler: RunLoop.main) // some magic to avoid jitter + .sink { [weak self] rootItem, ancestorItems, descendantItems, existObjectIDs in guard let self = self else { return } guard let tableView = self.tableView, let navigationBar = self.contentOffsetAdjustableTimelineViewControllerDelegate?.navigationBar() @@ -65,31 +83,42 @@ extension ThreadViewModel { if self.rootNode.value?.replyToID != nil, !(currentState is LoadThreadState.NoMore) { newSnapshot.appendItems([.topLoader], toSection: .main) } + + let ancestorItems = ancestorItems.filter { item in + guard case let .reply(statusObjectID, _) = item else { return false } + return existObjectIDs.contains(statusObjectID) + } newSnapshot.appendItems(ancestorItems, toSection: .main) // root - if let rootItem = rootItem { - switch rootItem { - case .root: - newSnapshot.appendItems([rootItem], toSection: .main) - default: - break - } + if let rootItem = rootItem, + case let .root(objectID, _) = rootItem, + existObjectIDs.contains(objectID) { + newSnapshot.appendItems([rootItem], toSection: .main) } // leaf if !(currentState is LoadThreadState.NoMore) { newSnapshot.appendItems([.bottomLoader], toSection: .main) } + + let descendantItems = descendantItems.filter { item in + switch item { + case .leaf(let statusObjectID, _): + return existObjectIDs.contains(statusObjectID) + default: + return true + } + } newSnapshot.appendItems(descendantItems, toSection: .main) - // difference for first visiable item exclude .topLoader + // difference for first visible item exclude .topLoader guard let difference = self.calculateReloadSnapshotDifference(navigationBar: navigationBar, tableView: tableView, oldSnapshot: oldSnapshot, newSnapshot: newSnapshot) else { diffableDataSource.apply(newSnapshot) return } - // addtional margin for .topLoader + // additional margin for .topLoader let oldTopMargin: CGFloat = { let marginHeight = TimelineTopLoaderTableViewCell.cellHeight if oldSnapshot.itemIdentifiers.contains(.topLoader) { @@ -184,3 +213,33 @@ extension ThreadViewModel { ) } } + +extension ThreadViewModel { + private func updateDeletedStatus(for items: [Item]) { + let parentManagedObjectContext = context.managedObjectContext + let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) + managedObjectContext.parent = parentManagedObjectContext + managedObjectContext.perform { + var statusIDs: [Status.ID] = [] + for item in items { + switch item { + case .root(let objectID, _): + guard let status = managedObjectContext.object(with: objectID) as? Status else { continue } + statusIDs.append(status.id) + case .reply(let objectID, _): + guard let status = managedObjectContext.object(with: objectID) as? Status else { continue } + statusIDs.append(status.id) + case .leaf(let objectID, _): + guard let status = managedObjectContext.object(with: objectID) as? Status else { continue } + statusIDs.append(status.id) + default: + continue + } + } + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.existStatusFetchedResultsController.statusIDs.value = statusIDs + } + } + } +} diff --git a/Mastodon/Scene/Thread/ThreadViewModel.swift b/Mastodon/Scene/Thread/ThreadViewModel.swift index 50df678c6..febc34d17 100644 --- a/Mastodon/Scene/Thread/ThreadViewModel.swift +++ b/Mastodon/Scene/Thread/ThreadViewModel.swift @@ -16,12 +16,14 @@ import MastodonSDK class ThreadViewModel { var disposeBag = Set() + var rootItemObserver: AnyCancellable? // input let context: AppContext let rootNode: CurrentValueSubject let rootItem: CurrentValueSubject let cellFrameCache = NSCache() + let existStatusFetchedResultsController: StatusFetchedResultsController weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? weak var tableView: UITableView? @@ -49,10 +51,20 @@ class ThreadViewModel { self.context = context self.rootNode = CurrentValueSubject(optionalStatus.flatMap { RootNode(domain: $0.domain, statusID: $0.id, replyToID: $0.inReplyToID) }) self.rootItem = CurrentValueSubject(optionalStatus.flatMap { Item.root(statusObjectID: $0.objectID, attribute: Item.StatusAttribute()) }) + self.existStatusFetchedResultsController = StatusFetchedResultsController(managedObjectContext: context.managedObjectContext, domain: nil, additionalTweetPredicate: nil) self.navigationBarTitle = CurrentValueSubject( optionalStatus.flatMap { L10n.Scene.Thread.title($0.author.displayNameWithFallback) } ) + // bind fetcher domain + context.authenticationService.activeMastodonAuthenticationBox + .receive(on: RunLoop.main) + .sink { [weak self] box in + guard let self = self else { return } + self.existStatusFetchedResultsController.domain.value = box?.domain + } + .store(in: &disposeBag) + rootNode .receive(on: DispatchQueue.main) .sink { [weak self] rootNode in @@ -79,8 +91,32 @@ class ThreadViewModel { .store(in: &disposeBag) } - // descendantNodes - + rootItem + .receive(on: DispatchQueue.main) + .sink { [weak self] rootItem in + guard let self = self else { return } + guard case let .root(objectID, _) = rootItem else { return } + self.context.managedObjectContext.perform { + guard let status = self.context.managedObjectContext.object(with: objectID) as? Status else { + return + } + self.rootItemObserver = ManagedObjectObserver.observe(object: status) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in + // do nothing + }, receiveValue: { [weak self] change in + guard let self = self else { return } + switch change.changeType { + case .delete: + self.rootItem.value = nil + default: + break + } + }) + } + } + .store(in: &disposeBag) + ancestorNodes .receive(on: DispatchQueue.main) .compactMap { [weak self] nodes -> [Item]? in @@ -276,4 +312,3 @@ extension ThreadViewModel { } } - From 2c2aa127bfdf9de4141a14c58721167b4ff9e1f3 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 21 Jun 2021 17:52:38 +0800 Subject: [PATCH 3/8] fix: wrong apostrophe for i18n issue --- Localization/app.json | 10 +++++----- Mastodon/Generated/Strings.swift | 10 +++++----- Mastodon/Resources/ar.lproj/Localizable.strings | 10 +++++----- Mastodon/Resources/en.lproj/Localizable.strings | 10 +++++----- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index 90a63fc18..95410b8bb 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -187,7 +187,7 @@ "blocking_warning": "You can’t view Artbot’s profile\n until you unblock them.\nYour account looks like this to them.", "blocked_warning": "You can’t view Artbot’s profile\n until they unblock you.", "suspended_warning": "This account has been suspended.", - "user_suspended_warning": "%s's account has been suspended." + "user_suspended_warning": "%s’s account has been suspended." }, "accessibility": { "count_replies": "%s replies", @@ -290,7 +290,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)", + "username_too_long": "Username is too long (can’t be longer than 30 characters)", "email_invalid": "This is not a valid e-mail address", "password_too_short": "Password is too short (must be at least 8 characters)" } @@ -299,7 +299,7 @@ "server_rules": { "title": "Some ground rules.", "subtitle": "These rules are set by the admins of %s.", - "prompt": "By continuing, you're subject to the terms of service and privacy policy for %s.", + "prompt": "By continuing, you’re subject to the terms of service and privacy policy for %s.", "terms_of_service": "terms of service", "privacy_policy": "privacy policy", "button": { @@ -351,13 +351,13 @@ "photo_library": "Photo Library", "browse": "Browse" }, - "content_input_placeholder": "Type or paste what's on your mind", + "content_input_placeholder": "Type or paste what’s on your mind", "compose_action": "Publish", "replying_to_user": "replying to %s", "attachment": { "photo": "photo", "video": "video", - "attachment_broken": "This %s is broken and can't be\nuploaded to Mastodon.", + "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..." }, diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 072105193..d1014b4be 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -367,7 +367,7 @@ internal enum L10n { internal static let noStatusFound = L10n.tr("Localizable", "Common.Controls.Timeline.Header.NoStatusFound") /// This account has been suspended. internal static let suspendedWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.SuspendedWarning") - /// %@'s account has been suspended. + /// %@’s account has been suspended. internal static func userSuspendedWarning(_ p1: Any) -> String { return L10n.tr("Localizable", "Common.Controls.Timeline.Header.UserSuspendedWarning", String(describing: p1)) } @@ -404,7 +404,7 @@ internal enum L10n { internal enum Compose { /// Publish internal static let composeAction = L10n.tr("Localizable", "Scene.Compose.ComposeAction") - /// Type or paste what's on your mind + /// Type or paste what’s on your mind internal static let contentInputPlaceholder = L10n.tr("Localizable", "Scene.Compose.ContentInputPlaceholder") /// replying to %@ internal static func replyingToUser(_ p1: Any) -> String { @@ -435,7 +435,7 @@ internal enum L10n { internal static let removePoll = L10n.tr("Localizable", "Scene.Compose.Accessibility.RemovePoll") } internal enum Attachment { - /// This %@ is broken and can't be\nuploaded to Mastodon. + /// This %@ is broken and can’t be\nuploaded to Mastodon. internal static func attachmentBroken(_ p1: Any) -> String { return L10n.tr("Localizable", "Scene.Compose.Attachment.AttachmentBroken", String(describing: p1)) } @@ -756,7 +756,7 @@ internal enum L10n { internal static let passwordTooShort = L10n.tr("Localizable", "Scene.Register.Error.Special.PasswordTooShort") /// Username must only contain alphanumeric characters and underscores internal static let usernameInvalid = L10n.tr("Localizable", "Scene.Register.Error.Special.UsernameInvalid") - /// Username is too long (can't be longer than 30 characters) + /// Username is too long (can’t be longer than 30 characters) internal static let usernameTooLong = L10n.tr("Localizable", "Scene.Register.Error.Special.UsernameTooLong") } } @@ -918,7 +918,7 @@ internal enum L10n { internal enum ServerRules { /// privacy policy internal static let privacyPolicy = L10n.tr("Localizable", "Scene.ServerRules.PrivacyPolicy") - /// By continuing, you're subject to the terms of service and privacy policy for %@. + /// By continuing, you’re subject to the terms of service and privacy policy for %@. internal static func prompt(_ p1: Any) -> String { return L10n.tr("Localizable", "Scene.ServerRules.Prompt", String(describing: p1)) } diff --git a/Mastodon/Resources/ar.lproj/Localizable.strings b/Mastodon/Resources/ar.lproj/Localizable.strings index 3d976d72a..ad7e4dd8d 100644 --- a/Mastodon/Resources/ar.lproj/Localizable.strings +++ b/Mastodon/Resources/ar.lproj/Localizable.strings @@ -128,7 +128,7 @@ Please check your internet connection."; 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."; -"Common.Controls.Timeline.Header.UserSuspendedWarning" = "%@'s account has been suspended."; +"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"; @@ -145,7 +145,7 @@ Your account looks like this to them."; "Scene.Compose.Accessibility.InputLimitRemainsCount" = "Input limit remains %ld"; "Scene.Compose.Accessibility.PostVisibilityMenu" = "Post visibility menu"; "Scene.Compose.Accessibility.RemovePoll" = "Remove poll"; -"Scene.Compose.Attachment.AttachmentBroken" = "This %@ is broken and can't be +"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..."; @@ -154,7 +154,7 @@ uploaded to Mastodon."; "Scene.Compose.AutoComplete.MultiplePeopleTalking" = "%ld people talking"; "Scene.Compose.AutoComplete.SinglePeopleTalking" = "%ld people talking"; "Scene.Compose.ComposeAction" = "Publish"; -"Scene.Compose.ContentInputPlaceholder" = "Type or paste what's on your mind"; +"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.DiscardPost" = "Discard Post"; @@ -249,7 +249,7 @@ tap the link to confirm your account."; "Scene.Register.Error.Special.EmailInvalid" = "This is not a valid e-mail 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)"; +"Scene.Register.Error.Special.UsernameTooLong" = "Username is too long (can’t be longer than 30 characters)"; "Scene.Register.Input.Avatar.Delete" = "Delete"; "Scene.Register.Input.DisplayName.Placeholder" = "display name"; "Scene.Register.Input.Email.Placeholder" = "email"; @@ -308,7 +308,7 @@ tap the link to confirm your account."; any server."; "Scene.ServerRules.Button.Confirm" = "I Agree"; "Scene.ServerRules.PrivacyPolicy" = "privacy policy"; -"Scene.ServerRules.Prompt" = "By continuing, you're subject to the terms of service and privacy policy for %@."; +"Scene.ServerRules.Prompt" = "By continuing, you’re subject to the terms of service and privacy policy for %@."; "Scene.ServerRules.Subtitle" = "These rules are set by the admins of %@."; "Scene.ServerRules.TermsOfService" = "terms of service"; "Scene.ServerRules.Title" = "Some ground rules."; diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 3d976d72a..ad7e4dd8d 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -128,7 +128,7 @@ Please check your internet connection."; 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."; -"Common.Controls.Timeline.Header.UserSuspendedWarning" = "%@'s account has been suspended."; +"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"; @@ -145,7 +145,7 @@ Your account looks like this to them."; "Scene.Compose.Accessibility.InputLimitRemainsCount" = "Input limit remains %ld"; "Scene.Compose.Accessibility.PostVisibilityMenu" = "Post visibility menu"; "Scene.Compose.Accessibility.RemovePoll" = "Remove poll"; -"Scene.Compose.Attachment.AttachmentBroken" = "This %@ is broken and can't be +"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..."; @@ -154,7 +154,7 @@ uploaded to Mastodon."; "Scene.Compose.AutoComplete.MultiplePeopleTalking" = "%ld people talking"; "Scene.Compose.AutoComplete.SinglePeopleTalking" = "%ld people talking"; "Scene.Compose.ComposeAction" = "Publish"; -"Scene.Compose.ContentInputPlaceholder" = "Type or paste what's on your mind"; +"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.DiscardPost" = "Discard Post"; @@ -249,7 +249,7 @@ tap the link to confirm your account."; "Scene.Register.Error.Special.EmailInvalid" = "This is not a valid e-mail 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)"; +"Scene.Register.Error.Special.UsernameTooLong" = "Username is too long (can’t be longer than 30 characters)"; "Scene.Register.Input.Avatar.Delete" = "Delete"; "Scene.Register.Input.DisplayName.Placeholder" = "display name"; "Scene.Register.Input.Email.Placeholder" = "email"; @@ -308,7 +308,7 @@ tap the link to confirm your account."; any server."; "Scene.ServerRules.Button.Confirm" = "I Agree"; "Scene.ServerRules.PrivacyPolicy" = "privacy policy"; -"Scene.ServerRules.Prompt" = "By continuing, you're subject to the terms of service and privacy policy for %@."; +"Scene.ServerRules.Prompt" = "By continuing, you’re subject to the terms of service and privacy policy for %@."; "Scene.ServerRules.Subtitle" = "These rules are set by the admins of %@."; "Scene.ServerRules.TermsOfService" = "terms of service"; "Scene.ServerRules.Title" = "Some ground rules."; From e753da5ca47741e73ae99cb717083648e57fca1e Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 21 Jun 2021 17:53:06 +0800 Subject: [PATCH 4/8] fix: content offset not take effect issue in compose scene --- Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift index 7a7eba12b..0b8d3e8f1 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift @@ -69,6 +69,9 @@ extension ComposeViewModel { snapshot.appendItems([.input(replyToStatusObjectID: nil, attribute: composeStatusAttribute)], toSection: .status) } diffableDataSource.apply(snapshot, animatingDifferences: false) + + // some magic fix modal presentation animation issue + collectionView.dataSource = diffableDataSource } func setupCustomEmojiPickerDiffableDataSource( From 9fca59d40d61381e0bfb1faa1284e05199ddcd97 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 21 Jun 2021 17:58:16 +0800 Subject: [PATCH 5/8] fix: set dismiss key board on drag for compose scene --- Mastodon/Scene/Compose/ComposeViewController.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 6dfb77d3c..8eeff5512 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -55,6 +55,7 @@ final class ComposeViewController: UIViewController, NeedsDependency { collectionView.register(ComposeStatusPollExpiresOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollExpiresOptionCollectionViewCell.self)) collectionView.backgroundColor = Asset.Scene.Compose.background.color collectionView.alwaysBounceVertical = true + collectionView.keyboardDismissMode = .onDrag return collectionView }() From b76f918a99b9ed63c30c6551248fb852560a8ec4 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 21 Jun 2021 18:06:10 +0800 Subject: [PATCH 6/8] fix: set character count label font to monospace --- Mastodon/Scene/Compose/ComposeViewController.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 8eeff5512..0c1bf3a34 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -381,11 +381,11 @@ extension ComposeViewController { self.composeToolbarView.characterCountLabel.text = "\(count)" switch count { case _ where count < 0: - self.composeToolbarView.characterCountLabel.font = .systemFont(ofSize: 24, weight: .bold) + self.composeToolbarView.characterCountLabel.font = .monospacedDigitSystemFont(ofSize: 24, weight: .bold) self.composeToolbarView.characterCountLabel.textColor = Asset.Colors.danger.color self.composeToolbarView.characterCountLabel.accessibilityLabel = L10n.Scene.Compose.Accessibility.inputLimitExceedsCount(abs(count)) default: - self.composeToolbarView.characterCountLabel.font = .systemFont(ofSize: 15, weight: .regular) + self.composeToolbarView.characterCountLabel.font = .monospacedDigitSystemFont(ofSize: 15, weight: .regular) self.composeToolbarView.characterCountLabel.textColor = Asset.Colors.Label.secondary.color self.composeToolbarView.characterCountLabel.accessibilityLabel = L10n.Scene.Compose.Accessibility.inputLimitRemainsCount(count) } From f826cacccfbd5dab7cac895648d678e3c870869c Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 21 Jun 2021 18:30:24 +0800 Subject: [PATCH 7/8] fix: add interact hint label and separator line for emoji auto complete in compose scene --- Localization/app.json | 3 ++- Mastodon/Diffiable/Section/AutoCompleteSection.swift | 6 ++++-- Mastodon/Generated/Strings.swift | 2 ++ Mastodon/Resources/ar.lproj/Localizable.strings | 1 + Mastodon/Resources/en.lproj/Localizable.strings | 1 + .../AutoComplete/Cell/AutoCompleteTableViewCell.swift | 11 +++++++++++ 6 files changed, 21 insertions(+), 3 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index 95410b8bb..f370022ab 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -382,7 +382,8 @@ }, "auto_complete": { "single_people_talking": "%ld people talking", - "multiple_people_talking": "%ld people talking" + "multiple_people_talking": "%ld people talking", + "space_to_add": "Space to add" }, "accessibility": { "append_attachment": "Append attachment", diff --git a/Mastodon/Diffiable/Section/AutoCompleteSection.swift b/Mastodon/Diffiable/Section/AutoCompleteSection.swift index 39aa6e9cc..8de32a284 100644 --- a/Mastodon/Diffiable/Section/AutoCompleteSection.swift +++ b/Mastodon/Diffiable/Section/AutoCompleteSection.swift @@ -33,7 +33,7 @@ extension AutoCompleteSection { return cell case .emoji(let emoji): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: AutoCompleteTableViewCell.self), for: indexPath) as! AutoCompleteTableViewCell - configureEmoji(cell: cell, emoji: emoji) + configureEmoji(cell: cell, emoji: emoji, isFirst: indexPath.row == 0) return cell case .bottomLoader: let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell @@ -80,8 +80,10 @@ extension AutoCompleteSection { cell.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: URL(string: account.avatar))) } - private static func configureEmoji(cell: AutoCompleteTableViewCell, emoji: Mastodon.Entity.Emoji) { + private static func configureEmoji(cell: AutoCompleteTableViewCell, emoji: Mastodon.Entity.Emoji, isFirst: Bool) { cell.titleLabel.text = ":" + emoji.shortcode + ":" + // FIXME: handle spacer enter to complete emoji + // cell.subtitleLabel.text = isFirst ? L10n.Scene.Compose.AutoComplete.spaceToAdd : " " cell.subtitleLabel.text = " " cell.avatarImageView.isHidden = false cell.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: URL(string: emoji.url))) diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index d1014b4be..07cab3974 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -457,6 +457,8 @@ internal enum L10n { internal static func singlePeopleTalking(_ p1: Int) -> String { return L10n.tr("Localizable", "Scene.Compose.AutoComplete.SinglePeopleTalking", p1) } + /// Space to add + internal static let spaceToAdd = L10n.tr("Localizable", "Scene.Compose.AutoComplete.SpaceToAdd") } internal enum ContentWarning { /// Write an accurate warning here... diff --git a/Mastodon/Resources/ar.lproj/Localizable.strings b/Mastodon/Resources/ar.lproj/Localizable.strings index ad7e4dd8d..e6bb7b217 100644 --- a/Mastodon/Resources/ar.lproj/Localizable.strings +++ b/Mastodon/Resources/ar.lproj/Localizable.strings @@ -153,6 +153,7 @@ uploaded to Mastodon."; "Scene.Compose.Attachment.Video" = "video"; "Scene.Compose.AutoComplete.MultiplePeopleTalking" = "%ld people talking"; "Scene.Compose.AutoComplete.SinglePeopleTalking" = "%ld people talking"; +"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..."; diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index ad7e4dd8d..e6bb7b217 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -153,6 +153,7 @@ uploaded to Mastodon."; "Scene.Compose.Attachment.Video" = "video"; "Scene.Compose.AutoComplete.MultiplePeopleTalking" = "%ld people talking"; "Scene.Compose.AutoComplete.SinglePeopleTalking" = "%ld people talking"; +"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..."; diff --git a/Mastodon/Scene/Compose/AutoComplete/Cell/AutoCompleteTableViewCell.swift b/Mastodon/Scene/Compose/AutoComplete/Cell/AutoCompleteTableViewCell.swift index 324f5354e..c9f0a55d1 100644 --- a/Mastodon/Scene/Compose/AutoComplete/Cell/AutoCompleteTableViewCell.swift +++ b/Mastodon/Scene/Compose/AutoComplete/Cell/AutoCompleteTableViewCell.swift @@ -45,6 +45,8 @@ final class AutoCompleteTableViewCell: UITableViewCell { return label }() + let separatorLine = UIView.separatorLine + override func prepareForReuse() { super.prepareForReuse() avatarImageView.af.cancelImageRequest() @@ -118,6 +120,15 @@ extension AutoCompleteTableViewCell { bottomPaddingView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), bottomPaddingView.heightAnchor.constraint(equalTo: topPaddingView.heightAnchor, multiplier: 1.0), ]) + + separatorLine.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(separatorLine) + NSLayoutConstraint.activate([ + separatorLine.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + separatorLine.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + separatorLine.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)).priority(.defaultHigh), + ]) } } From b4c4153aaa40de35da9d8160d9cef7c754882357 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 21 Jun 2021 18:58:58 +0800 Subject: [PATCH 8/8] feat: scroll to top when tap title view in home scene --- .../HomeTimelineViewController.swift | 4 +++ .../HomeTimelineNavigationBarTitleView.swift | 29 ++++++++++++------- ...eTimelineNavigationBarTitleViewModel.swift | 6 ++-- 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 529f2d81c..b40a7f3ad 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -526,6 +526,10 @@ extension HomeTimelineViewController: StatusTableViewCellDelegate { // MARK: - HomeTimelineNavigationBarTitleViewDelegate extension HomeTimelineViewController: HomeTimelineNavigationBarTitleViewDelegate { + func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, logoButtonDidPressed sender: UIButton) { + scrollToTop(animated: true) + } + func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, buttonDidPressed sender: UIButton) { switch titleView.state { case .newPostButton: diff --git a/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleView.swift b/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleView.swift index 91020f12a..ac39ce1ae 100644 --- a/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleView.swift +++ b/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleView.swift @@ -9,6 +9,7 @@ import os.log import UIKit protocol HomeTimelineNavigationBarTitleViewDelegate: AnyObject { + func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, logoButtonDidPressed sender: UIButton) func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, buttonDidPressed sender: UIButton) } @@ -16,7 +17,7 @@ final class HomeTimelineNavigationBarTitleView: UIView { let containerView = UIStackView() - let imageView = UIImageView() + let logoButton = HighlightDimmableButton() let button = RoundedEdgesButton() let label = UILabel() @@ -25,7 +26,7 @@ final class HomeTimelineNavigationBarTitleView: UIView { weak var delegate: HomeTimelineNavigationBarTitleViewDelegate? // output - private(set) var state: HomeTimelineNavigationBarTitleViewModel.State = .logoImage + private(set) var state: HomeTimelineNavigationBarTitleViewModel.State = .logo override init(frame: CGRect) { super.init(frame: frame) @@ -50,7 +51,7 @@ extension HomeTimelineNavigationBarTitleView { containerView.bottomAnchor.constraint(equalTo: bottomAnchor), ]) - containerView.addArrangedSubview(imageView) + containerView.addArrangedSubview(logoButton) button.translatesAutoresizingMaskIntoConstraints = false containerView.addArrangedSubview(button) NSLayoutConstraint.activate([ @@ -58,12 +59,18 @@ extension HomeTimelineNavigationBarTitleView { ]) containerView.addArrangedSubview(label) - configure(state: .logoImage) + configure(state: .logo) + logoButton.addTarget(self, action: #selector(HomeTimelineNavigationBarTitleView.logoButtonDidPressed(_:)), for: .touchUpInside) button.addTarget(self, action: #selector(HomeTimelineNavigationBarTitleView.buttonDidPressed(_:)), for: .touchUpInside) } } extension HomeTimelineNavigationBarTitleView { + @objc private func logoButtonDidPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.homeTimelineNavigationBarTitleView(self, logoButtonDidPressed: sender) + } + @objc private func buttonDidPressed(_ sender: UIButton) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) delegate?.homeTimelineNavigationBarTitleView(self, buttonDidPressed: sender) @@ -73,7 +80,7 @@ extension HomeTimelineNavigationBarTitleView { extension HomeTimelineNavigationBarTitleView { func resetContainer() { - imageView.isHidden = true + logoButton.isHidden = true button.isHidden = true label.isHidden = true } @@ -90,11 +97,11 @@ extension HomeTimelineNavigationBarTitleView { resetContainer() switch state { - case .logoImage: - imageView.tintColor = Asset.Colors.Label.primary.color - imageView.image = Asset.Asset.mastodonTextLogo.image.withRenderingMode(.alwaysTemplate) - imageView.contentMode = .center - imageView.isHidden = false + case .logo: + logoButton.tintColor = Asset.Colors.Label.primary.color + logoButton.setImage(Asset.Asset.mastodonTextLogo.image.withRenderingMode(.alwaysTemplate), for: .normal) + logoButton.contentMode = .center + logoButton.isHidden = false case .newPostButton: configureButton( title: L10n.Scene.HomeTimeline.NavigationBarState.newPosts, @@ -173,7 +180,7 @@ struct HomeTimelineNavigationBarTitleView_Previews: PreviewProvider { Group { UIViewPreview(width: 375) { let titleView = HomeTimelineNavigationBarTitleView() - titleView.configure(state: .logoImage) + titleView.configure(state: .logo) return titleView } .previewLayout(.fixed(width: 375, height: 44)) diff --git a/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleViewModel.swift b/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleViewModel.swift index e1fc3174e..71b4dda8b 100644 --- a/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleViewModel.swift +++ b/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleViewModel.swift @@ -22,7 +22,7 @@ final class HomeTimelineNavigationBarTitleViewModel { var networkErrorPublisher = PassthroughSubject() // output - let state = CurrentValueSubject(.logoImage) + let state = CurrentValueSubject(.logo) let hasNewPosts = CurrentValueSubject(false) let isOffline = CurrentValueSubject(false) let isPublishingPost = CurrentValueSubject(false) @@ -75,7 +75,7 @@ final class HomeTimelineNavigationBarTitleViewModel { guard !isPublishingPost else { return .publishingPostLabel } guard !isOffline else { return .offlineButton } guard !hasNewPosts else { return .newPostButton } - return .logoImage + return .logo } .receive(on: DispatchQueue.main) .assign(to: \.value, on: state) @@ -100,7 +100,7 @@ final class HomeTimelineNavigationBarTitleViewModel { extension HomeTimelineNavigationBarTitleViewModel { // state order by priority from low to high enum State: String { - case logoImage + case logo case newPostButton case offlineButton case publishingPostLabel