diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index 9dc908c0f..1d517412a 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -189,6 +189,7 @@ + @@ -281,7 +282,7 @@ - + diff --git a/CoreDataStack/Entity/Setting.swift b/CoreDataStack/Entity/Setting.swift index 953f773fe..88768eda4 100644 --- a/CoreDataStack/Entity/Setting.swift +++ b/CoreDataStack/Entity/Setting.swift @@ -10,10 +10,12 @@ import Foundation public final class Setting: NSManagedObject { - @NSManaged public var appearanceRaw: String - @NSManaged public var preferredTrueBlackDarkMode: Bool @NSManaged public var domain: String @NSManaged public var userID: String + + @NSManaged public var appearanceRaw: String + @NSManaged public var preferredTrueBlackDarkMode: Bool + @NSManaged public var preferredStaticAvatar: Bool @NSManaged public private(set) var createdAt: Date @NSManaged public private(set) var updatedAt: Date @@ -54,6 +56,12 @@ extension Setting { self.preferredTrueBlackDarkMode = preferredTrueBlackDarkMode didUpdate(at: Date()) } + + public func update(preferredStaticAvatar: Bool) { + guard preferredStaticAvatar != self.preferredStaticAvatar else { return } + self.preferredStaticAvatar = preferredStaticAvatar + didUpdate(at: Date()) + } public func didUpdate(at networkDate: Date) { self.updatedAt = networkDate diff --git a/Localization/app.json b/Localization/app.json index 28119a48c..5c8dd5cc0 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -200,7 +200,7 @@ "slogan": "Social networking\nback in your hands." }, "server_picker": { - "title": "Pick a Server,\nany server.", + "title": "Pick a server,\nany server.", "button": { "category": { "all": "All", @@ -497,6 +497,9 @@ "appearance_settings": { "dark_mode": { "title": "True black Dark Mode" + }, + "avatar_animation": { + "title": "Disable avatar animation" } }, "notifications": { diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index d87a1b8ae..73ab14602 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -411,6 +411,7 @@ DB9D6C2E25E504AC0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2D25E504AC0051B173 /* Attachment.swift */; }; DB9D6C3825E508BE0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C3725E508BE0051B173 /* Attachment.swift */; }; DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */; }; + DBA088DF26958164003EB4B2 /* UserFetchedResultsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA088DE26958164003EB4B2 /* UserFetchedResultsController.swift */; }; DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */; }; DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */; }; DBA1DB80268F84F80052DB59 /* NotificationType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA1DB7F268F84F80052DB59 /* NotificationType.swift */; }; @@ -1039,6 +1040,7 @@ DB9D6C2D25E504AC0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; DB9D6C3725E508BE0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIInterpolatingMotionEffect.swift; sourceTree = ""; }; + DBA088DE26958164003EB4B2 /* UserFetchedResultsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserFetchedResultsController.swift; sourceTree = ""; }; DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedEdgesButton.swift; sourceTree = ""; }; DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = ""; }; DBA1DB7F268F84F80052DB59 /* NotificationType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationType.swift; sourceTree = ""; }; @@ -2607,6 +2609,7 @@ isa = PBXGroup; children = ( DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */, + DBA088DE26958164003EB4B2 /* UserFetchedResultsController.swift */, DB6D9F75263587C7008423CD /* SettingFetchedResultController.swift */, ); path = FetchedResultsController; @@ -3256,6 +3259,7 @@ DBA9443E265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift in Sources */, 2D24E1232626ED9D00A59D4F /* UIView+Gesture.swift in Sources */, DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */, + DBA088DF26958164003EB4B2 /* UserFetchedResultsController.swift in Sources */, DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */, 0F202213261351F5000C64BF /* APIService+HashtagTimeline.swift in Sources */, DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */, @@ -4829,7 +4833,7 @@ repositoryURL = "https://github.com/TwidereProject/MetaTextView.git"; requirement = { kind = exactVersion; - version = 1.2.5; + version = 1.3.0; }; }; DB0E2D2C26833FF600865C3C /* XCRemoteSwiftPackageReference "Nuke-FLAnimatedImage-Plugin" */ = { diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 0f3da3a13..ecde1bcc5 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 - 21 + 20 Mastodon - ASDK.xcscheme_^#shared#^_ @@ -37,7 +37,7 @@ NotificationService.xcscheme_^#shared#^_ orderHint - 20 + 21 SuppressBuildableAutocreation diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index e143e8a71..1f3cc3145 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -114,8 +114,8 @@ "repositoryURL": "https://github.com/TwidereProject/MetaTextView.git", "state": { "branch": null, - "revision": "9ba4027ed0a88185ce95bb1773620c2ceaa9f3bb", - "version": "1.2.5" + "revision": "e2049e14ef411c6810d53c1baf553b5161c6678f", + "version": "1.3.0" } }, { diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index 310a819b5..b918e1812 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -141,7 +141,11 @@ extension SceneCoordinator { if let navigationControllerVisibleViewController = presentingViewController.navigationController?.visibleViewController { switch viewController { case is ProfileViewController: - let barButtonItem = UIBarButtonItem(title: navigationControllerVisibleViewController.navigationItem.title, style: .plain, target: nil, action: nil) + let title: String = { + let title = navigationControllerVisibleViewController.navigationItem.title ?? "" + return title.count > 10 ? "" : title + }() + let barButtonItem = UIBarButtonItem(title: title, style: .plain, target: nil, action: nil) barButtonItem.tintColor = .white navigationControllerVisibleViewController.navigationItem.backBarButtonItem = barButtonItem default: diff --git a/Mastodon/Diffiable/FetchedResultsController/UserFetchedResultsController.swift b/Mastodon/Diffiable/FetchedResultsController/UserFetchedResultsController.swift new file mode 100644 index 000000000..f46ee978d --- /dev/null +++ b/Mastodon/Diffiable/FetchedResultsController/UserFetchedResultsController.swift @@ -0,0 +1,87 @@ +// +// UserFetchedResultsController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-7-7. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +final class UserFetchedResultsController: NSObject { + + var disposeBag = Set() + + let fetchedResultsController: NSFetchedResultsController + + // input + let domain = CurrentValueSubject(nil) + let userIDs = CurrentValueSubject<[Mastodon.Entity.Account.ID], Never>([]) + + // output + let objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([]) + + init(managedObjectContext: NSManagedObjectContext, domain: String?, additionalTweetPredicate: NSPredicate?) { + self.domain.value = domain ?? "" + self.fetchedResultsController = { + let fetchRequest = MastodonUser.sortedFetchRequest + fetchRequest.predicate = MastodonUser.predicate(domain: domain ?? "", ids: []) + fetchRequest.returnsObjectsAsFaults = false + fetchRequest.fetchBatchSize = 20 + let controller = NSFetchedResultsController( + fetchRequest: fetchRequest, + managedObjectContext: managedObjectContext, + sectionNameKeyPath: nil, + cacheName: nil + ) + + return controller + }() + super.init() + + fetchedResultsController.delegate = self + + Publishers.CombineLatest( + self.domain.removeDuplicates().eraseToAnyPublisher(), + self.userIDs.removeDuplicates().eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] domain, ids in + guard let self = self else { return } + var predicates = [MastodonUser.predicate(domain: domain ?? "", ids: ids)] + if let additionalPredicate = additionalTweetPredicate { + predicates.append(additionalPredicate) + } + self.fetchedResultsController.fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) + do { + try self.fetchedResultsController.performFetch() + } catch { + assertionFailure(error.localizedDescription) + } + } + .store(in: &disposeBag) + } + +} + +// MARK: - NSFetchedResultsControllerDelegate +extension UserFetchedResultsController: NSFetchedResultsControllerDelegate { + func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + let indexes = userIDs.value + let objects = fetchedResultsController.fetchedObjects ?? [] + + let items: [NSManagedObjectID] = objects + .compactMap { object in + indexes.firstIndex(of: object.id).map { index in (index, object) } + } + .sorted { $0.0 < $1.0 } + .map { $0.1.objectID } + self.objectIDs.value = items + } +} diff --git a/Mastodon/Diffiable/Item/SettingsItem.swift b/Mastodon/Diffiable/Item/SettingsItem.swift index f21dea31c..aca02474e 100644 --- a/Mastodon/Diffiable/Item/SettingsItem.swift +++ b/Mastodon/Diffiable/Item/SettingsItem.swift @@ -11,6 +11,7 @@ import CoreData enum SettingsItem: Hashable { case appearance(settingObjectID: NSManagedObjectID) case appearanceDarkMode(settingObjectID: NSManagedObjectID) + case appearanceDisableAvatarAnimation(settingObjectID: NSManagedObjectID) case notification(settingObjectID: NSManagedObjectID, switchMode: NotificationSwitchMode) case boringZone(item: Link) case spicyZone(item: Link) diff --git a/Mastodon/Diffiable/Section/PollSection.swift b/Mastodon/Diffiable/Section/PollSection.swift index 872cc7aea..79ecd415d 100644 --- a/Mastodon/Diffiable/Section/PollSection.swift +++ b/Mastodon/Diffiable/Section/PollSection.swift @@ -68,7 +68,7 @@ extension PollSection { cell.pollOptionView.checkmarkImageView.isHidden = true case .off: ThemeService.shared.currentTheme - .receive(on: RunLoop.main) + .receive(on: DispatchQueue.main) .sink { [weak cell] theme in guard let cell = cell else { return } cell.pollOptionView.checkmarkBackgroundView.backgroundColor = theme.tertiarySystemBackgroundColor @@ -80,7 +80,7 @@ extension PollSection { cell.pollOptionView.checkmarkImageView.isHidden = true case .on: ThemeService.shared.currentTheme - .receive(on: RunLoop.main) + .receive(on: DispatchQueue.main) .sink { [weak cell] theme in guard let cell = cell else { return } cell.pollOptionView.checkmarkBackgroundView.backgroundColor = theme.tertiarySystemBackgroundColor diff --git a/Mastodon/Diffiable/Section/ProfileFieldSection.swift b/Mastodon/Diffiable/Section/ProfileFieldSection.swift index 82f88f657..1c8546eb3 100644 --- a/Mastodon/Diffiable/Section/ProfileFieldSection.swift +++ b/Mastodon/Diffiable/Section/ProfileFieldSection.swift @@ -39,7 +39,7 @@ extension ProfileFieldSection { .sink { [weak cell] name, emojiDict in guard let cell = cell else { return } cell.fieldView.titleActiveLabel.configure(field: name, emojiDict: emojiDict) - cell.fieldView.titleTextField.text = name + // only bind label. The text field should only set once } .store(in: &cell.disposeBag) @@ -55,7 +55,7 @@ extension ProfileFieldSection { .sink { [weak cell] value, emojiDict in guard let cell = cell else { return } cell.fieldView.valueActiveLabel.configure(field: value, emojiDict: emojiDict) - cell.fieldView.valueTextField.text = value + // only bind label. The text field should only set once } .store(in: &cell.disposeBag) diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index d3c5ba809..7b613dfd3 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -11,7 +11,7 @@ import CoreDataStack import os.log import UIKit import AVKit -import Nuke +import AlamofireImage import MastodonMeta // import LinkPresentation @@ -137,6 +137,9 @@ extension StatusSection { switch item { case .root: + // allow select content + cell.statusView.contentMetaText.textView.isSelectable = true + // configure thread meta StatusSection.configureThreadMeta(cell: cell, status: status) ManagedObjectObserver.observe(object: status.reblog ?? status) .receive(on: RunLoop.main) @@ -519,13 +522,7 @@ extension StatusSection { let name = author.displayName.isEmpty ? author.username : author.displayName return L10n.Common.Controls.Status.userRepliedTo(name) }() - MastodonStatusContent.parseResult(content: headerText, emojiDict: status.replyTo?.author.emojiDict ?? [:]) - .receive(on: DispatchQueue.main) - .sink { [weak cell] parseResult in - guard let cell = cell else { return } - cell.statusView.headerInfoLabel.configure(contentParseResult: parseResult) - } - .store(in: &cell.disposeBag) + cell.statusView.headerInfoLabel.configure(content: headerText, emojiDict: status.replyTo?.author.emojiDict ?? [:]) cell.statusView.headerInfoLabel.accessibilityLabel = headerText cell.statusView.headerInfoLabel.isAccessibilityElement = status.replyTo != nil } else { @@ -541,13 +538,7 @@ extension StatusSection { // name let author = (status.reblog ?? status).author let nameContent = author.displayNameWithFallback - MastodonStatusContent.parseResult(content: nameContent, emojiDict: author.emojiDict) - .receive(on: DispatchQueue.main) - .sink { [weak cell] parseResult in - guard let cell = cell else { return } - cell.statusView.nameLabel.configure(contentParseResult: parseResult) - } - .store(in: &cell.disposeBag) + cell.statusView.nameLabel.configure(content: nameContent, emojiDict: author.emojiDict) cell.statusView.nameLabel.accessibilityLabel = nameContent // username cell.statusView.usernameLabel.text = "@" + author.acct @@ -648,48 +639,32 @@ extension StatusSection { } .store(in: &cell.disposeBag) - let isSingleMosaicLayout = mosaics.count == 1 - // set image - let imageSize = CGSize( - width: mosaic.imageViewSize.width * imageView.traitCollection.displayScale, - height: mosaic.imageViewSize.height * imageView.traitCollection.displayScale - ) - let url: URL? = { + let url: URL = { if UIDevice.current.userInterfaceIdiom == .phone { return meta.previewURL ?? meta.url } return meta.url }() - let request = ImageRequest( - url: url, - processors: [ - ImageProcessors.Resize( - size: imageSize, - unit: .pixels, - contentMode: isSingleMosaicLayout ? .aspectFill : .aspectFit, - crop: isSingleMosaicLayout - ) - ] - ) - let options = ImageLoadingOptions( - transition: .fadeIn(duration: 0.2) - ) - Nuke.loadImage( - with: request, - options: options, - into: imageView - ) { result in - switch result { - case .failure: - break + // let imageSize = CGSize( + // width: mosaic.imageViewSize.width * imageView.traitCollection.displayScale, + // height: mosaic.imageViewSize.height * imageView.traitCollection.displayScale + // ) + // let imageFilter = AspectScaledToFillSizeFilter(size: imageSize) + + imageView.af.setImage( + withURL: url, + placeholderImage: UIImage.placeholder(color: .systemFill), + imageTransition: .crossDissolve(0.2) + ) { response in + switch response.result { case .success: statusItemAttribute.isImageLoaded.value = true + case .failure: + break } - }? - .store(in: &cell.statusView.statusMosaicImageViewContainer.imageTasks) - + } imageView.accessibilityLabel = meta.altText diff --git a/Mastodon/Extension/CoreDataStack/MastodonUser.swift b/Mastodon/Extension/CoreDataStack/MastodonUser.swift index 1e0fe7dad..b9be9165b 100644 --- a/Mastodon/Extension/CoreDataStack/MastodonUser.swift +++ b/Mastodon/Extension/CoreDataStack/MastodonUser.swift @@ -74,11 +74,12 @@ extension MastodonUser { } public func avatarImageURL() -> URL? { - return URL(string: avatar) + let string = UserDefaults.shared.preferredStaticAvatar ? avatarStatic ?? avatar : avatar + return URL(string: string) } public func avatarImageURLWithFallback(domain: String) -> URL { - return URL(string: avatar) ?? URL(string: "https://\(domain)/avatars/original/missing.png")! + return avatarImageURL() ?? URL(string: "https://\(domain)/avatars/original/missing.png")! } } diff --git a/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Account.swift b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Account.swift index 8fd6bd67a..8109371c4 100644 --- a/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Account.swift +++ b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Account.swift @@ -5,6 +5,7 @@ // Created by xiaojian sun on 2021/4/2. // +import UIKit import MastodonSDK extension Mastodon.Entity.Account: Hashable { @@ -16,3 +17,14 @@ extension Mastodon.Entity.Account: Hashable { return lhs.id == rhs.id } } + +extension Mastodon.Entity.Account { + public func avatarImageURL() -> URL? { + let string = UserDefaults.shared.preferredStaticAvatar ? avatarStatic ?? avatar : avatar + return URL(string: string) + } + + public func avatarImageURLWithFallback(domain: String) -> URL { + return avatarImageURL() ?? URL(string: "https://\(domain)/avatars/original/missing.png")! + } +} diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 28c732b28..ab2b75f40 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -830,7 +830,7 @@ internal enum L10n { } } internal enum ServerPicker { - /// Pick a Server,\nany server. + /// Pick a server,\nany server. internal static let title = L10n.tr("Localizable", "Scene.ServerPicker.Title") internal enum Button { /// See Less @@ -934,6 +934,10 @@ internal enum L10n { internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Appearance.Title") } internal enum AppearanceSettings { + internal enum AvatarAnimation { + /// Disable avatar animation + internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.AppearanceSettings.AvatarAnimation.Title") + } internal enum DarkMode { /// True black Dark Mode internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.AppearanceSettings.DarkMode.Title") diff --git a/Mastodon/Preference/AppearancePreference.swift b/Mastodon/Preference/AppearancePreference.swift index 78cf3d332..1b4c42807 100644 --- a/Mastodon/Preference/AppearancePreference.swift +++ b/Mastodon/Preference/AppearancePreference.swift @@ -17,4 +17,12 @@ extension UserDefaults { set { self[#function] = newValue.rawValue } } + @objc dynamic var preferredStaticAvatar: Bool { + get { + register(defaults: [#function: false]) + return bool(forKey: #function) + } + set { self[#function] = newValue } + } + } diff --git a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift index 79185338a..c9546696f 100644 --- a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift +++ b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift @@ -210,6 +210,40 @@ extension UserProviderFacade { ) -> UIMenu { var children: [UIMenuElement] = [] let name = mastodonUser.displayNameWithFallback + + if let shareUser = shareUser { + let shareAction = UIAction(title: L10n.Common.Controls.Actions.shareUser(name), image: UIImage(systemName: "square.and.arrow.up"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak provider] _ in + guard let provider = provider else { return } + let activityViewController = createActivityViewControllerForMastodonUser(mastodonUser: shareUser, dependency: provider) + provider.coordinator.present( + scene: .activityViewController( + activityViewController: activityViewController, + sourceView: sourceView, + barButtonItem: barButtonItem + ), + from: provider, + transition: .activityViewControllerPresent(animated: true, completion: nil) + ) + } + children.append(shareAction) + } + + if let shareStatus = shareStatus { + let shareAction = UIAction(title: L10n.Common.Controls.Actions.sharePost, image: UIImage(systemName: "square.and.arrow.up"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak provider] _ in + guard let provider = provider else { return } + let activityViewController = createActivityViewControllerForMastodonUser(status: shareStatus, dependency: provider) + provider.coordinator.present( + scene: .activityViewController( + activityViewController: activityViewController, + sourceView: sourceView, + barButtonItem: barButtonItem + ), + from: provider, + transition: .activityViewControllerPresent(animated: true, completion: nil) + ) + } + children.append(shareAction) + } if !isMyself { // mute @@ -316,40 +350,6 @@ extension UserProviderFacade { } } - if let shareUser = shareUser { - let shareAction = UIAction(title: L10n.Common.Controls.Actions.shareUser(name), image: UIImage(systemName: "square.and.arrow.up"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak provider] _ in - guard let provider = provider else { return } - let activityViewController = createActivityViewControllerForMastodonUser(mastodonUser: shareUser, dependency: provider) - provider.coordinator.present( - scene: .activityViewController( - activityViewController: activityViewController, - sourceView: sourceView, - barButtonItem: barButtonItem - ), - from: provider, - transition: .activityViewControllerPresent(animated: true, completion: nil) - ) - } - children.append(shareAction) - } - - if let shareStatus = shareStatus { - let shareAction = UIAction(title: L10n.Common.Controls.Actions.sharePost, image: UIImage(systemName: "square.and.arrow.up"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak provider] _ in - guard let provider = provider else { return } - let activityViewController = createActivityViewControllerForMastodonUser(status: shareStatus, dependency: provider) - provider.coordinator.present( - scene: .activityViewController( - activityViewController: activityViewController, - sourceView: sourceView, - barButtonItem: barButtonItem - ), - from: provider, - transition: .activityViewControllerPresent(animated: true, completion: nil) - ) - } - children.append(shareAction) - } - if let status = shareStatus, isMyself { let deleteAction = UIAction(title: L10n.Common.Controls.Actions.delete, image: UIImage(systemName: "delete.left"), identifier: nil, discoverabilityTitle: nil, attributes: [.destructive], state: .off) { [weak provider] _ in diff --git a/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/content.warning.overlay.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/content.warning.overlay.background.colorset/Contents.json index 485b4813a..54427c610 100644 --- a/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/content.warning.overlay.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Theme/Mastodon/Background/content.warning.overlay.background.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.263", - "green" : "0.208", - "red" : "0.192" + "blue" : "0x6E", + "green" : "0x57", + "red" : "0x4F" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/content.warning.overlay.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Theme/system/Background/content.warning.overlay.background.colorset/Contents.json index 485b4813a..d211d7df9 100644 --- a/Mastodon/Resources/Assets.xcassets/Theme/system/Background/content.warning.overlay.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Theme/system/Background/content.warning.overlay.background.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.263", - "green" : "0.208", - "red" : "0.192" + "blue" : "60", + "green" : "58", + "red" : "58" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/ar.lproj/Localizable.strings b/Mastodon/Resources/ar.lproj/Localizable.strings index ce34268c7..f6211e838 100644 --- a/Mastodon/Resources/ar.lproj/Localizable.strings +++ b/Mastodon/Resources/ar.lproj/Localizable.strings @@ -303,7 +303,7 @@ tap the link to confirm your account."; "Scene.ServerPicker.Label.Category" = "CATEGORY"; "Scene.ServerPicker.Label.Language" = "LANGUAGE"; "Scene.ServerPicker.Label.Users" = "USERS"; -"Scene.ServerPicker.Title" = "Pick a Server, +"Scene.ServerPicker.Title" = "Pick a server, any server."; "Scene.ServerRules.Button.Confirm" = "I Agree"; "Scene.ServerRules.PrivacyPolicy" = "privacy policy"; @@ -317,6 +317,7 @@ 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.AvatarAnimation.Title" = "Disable avatar animation"; "Scene.Settings.Section.AppearanceSettings.DarkMode.Title" = "True black Dark Mode"; "Scene.Settings.Section.Boringzone.Privacy" = "Privacy Policy"; "Scene.Settings.Section.Boringzone.Terms" = "Terms of Service"; diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index ce34268c7..f6211e838 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -303,7 +303,7 @@ tap the link to confirm your account."; "Scene.ServerPicker.Label.Category" = "CATEGORY"; "Scene.ServerPicker.Label.Language" = "LANGUAGE"; "Scene.ServerPicker.Label.Users" = "USERS"; -"Scene.ServerPicker.Title" = "Pick a Server, +"Scene.ServerPicker.Title" = "Pick a server, any server."; "Scene.ServerRules.Button.Confirm" = "I Agree"; "Scene.ServerRules.PrivacyPolicy" = "privacy policy"; @@ -317,6 +317,7 @@ 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.AvatarAnimation.Title" = "Disable avatar animation"; "Scene.Settings.Section.AppearanceSettings.DarkMode.Title" = "True black Dark Mode"; "Scene.Settings.Section.Boringzone.Privacy" = "Privacy Policy"; "Scene.Settings.Section.Boringzone.Terms" = "Terms of Service"; diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index fcd35cec1..728c92c21 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -205,10 +205,6 @@ extension HomeTimelineViewController { // needs trigger manually after onboarding dismiss setNeedsStatusBarAppearanceUpdate() - - if (viewModel.fetchedResultsController.fetchedObjects ?? []).isEmpty { - viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.Loading.self) - } } override func viewDidAppear(_ animated: Bool) { @@ -216,11 +212,10 @@ extension HomeTimelineViewController { viewModel.viewDidAppear.send() - DispatchQueue.main.async { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in guard let self = self else { return } - if (self.viewModel.fetchedResultsController.fetchedObjects ?? []).count == 0 { - self.viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.Loading.self) - } + // always try to refresh timeline after appear + self.viewModel.homeTimelineNeedRefresh.send() } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift index d22c52be3..9d8f6f709 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift @@ -135,8 +135,10 @@ final class HomeTimelineViewModel: NSObject { self?.loadLatestStateMachine.enter(LoadLatestState.Loading.self) } .store(in: &disposeBag) - + + // refresh after publish post homeTimelineNavigationBarTitleViewModel.isPublished + .delay(for: 2, scheduler: DispatchQueue.main) .sink { [weak self] isPublished in guard let self = self else { return } self.homeTimelineNeedRefresh.send() diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift index 24cfe90a3..618faf1c2 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift @@ -209,7 +209,12 @@ extension ProfileHeaderViewController { .sink { [weak self] isEditing, note, editingNote in guard let self = self else { return } self.profileHeaderView.bioActiveLabel.configure(note: note ?? "", emojiDict: [:]) // FIXME: custom emoji - self.profileHeaderView.bioTextEditorView.text = editingNote ?? "" + + // prevent duplicate set + let editingNote = editingNote ?? "" + if self.profileHeaderView.bioTextEditorView.text != editingNote { + self.profileHeaderView.bioTextEditorView.text = editingNote + } } .store(in: &disposeBag) diff --git a/Mastodon/Scene/Profile/Header/View/ProfileFieldView.swift b/Mastodon/Scene/Profile/Header/View/ProfileFieldView.swift index e008e1bce..956b8d6d9 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileFieldView.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileFieldView.swift @@ -37,6 +37,7 @@ final class ProfileFieldView: UIView { let valueActiveLabel: ActiveLabel = { let label = ActiveLabel(style: .profileFieldValue) label.configure(content: "value", emojiDict: [:]) + label.textAlignment = .right return label }() diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index fc29d308f..1ac0c4a4e 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -152,7 +152,11 @@ extension ProfileViewController { .store(in: &disposeBag) let barAppearance = UINavigationBarAppearance() - barAppearance.configureWithTransparentBackground() + if isModal { + barAppearance.configureWithDefaultBackground() + } else { + barAppearance.configureWithTransparentBackground() + } navigationItem.standardAppearance = barAppearance navigationItem.compactAppearance = barAppearance navigationItem.scrollEdgeAppearance = barAppearance @@ -228,10 +232,10 @@ extension ProfileViewController { overlayScrollView.refreshControl = refreshControl refreshControl.addTarget(self, action: #selector(ProfileViewController.refreshControlValueChanged(_:)), for: .valueChanged) - let postsUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter()) + let postsUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter(excludeReplies: true)) bind(userTimelineViewModel: postsUserTimelineViewModel) - let repliesUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter(excludeReplies: true)) + let repliesUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter(excludeReplies: false)) bind(userTimelineViewModel: repliesUserTimelineViewModel) let mediaUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter(onlyMedia: true)) diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index 5c443e09a..fd7a8c813 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -40,6 +40,8 @@ final class SearchViewModel: NSObject { var accountDiffableDataSource: UICollectionViewDiffableDataSource? var searchResultDiffableDataSource: UITableViewDiffableDataSource? + let statusFetchedResultsController: StatusFetchedResultsController + // bottom loader private(set) lazy var loadoldestStateMachine: GKStateMachine = { // exclude timeline middle fetcher state @@ -59,6 +61,11 @@ final class SearchViewModel: NSObject { 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 @@ -70,6 +77,7 @@ final class SearchViewModel: NSObject { return } self.currentMastodonUser.value = activeMastodonAuthentication.user + self.statusFetchedResultsController.domain.value = activeMastodonAuthentication.domain } .store(in: &disposeBag) @@ -224,7 +232,7 @@ final class SearchViewModel: NSObject { } } .store(in: &disposeBag) - + searchResult .receive(on: DispatchQueue.main) .sink { [weak self] searchResult in @@ -343,7 +351,7 @@ final class SearchViewModel: NSObject { 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 { diff --git a/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift b/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift index 6759eecba..6a40f7902 100644 --- a/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift +++ b/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift @@ -10,10 +10,13 @@ import CoreDataStack import Foundation import MastodonSDK import UIKit +import FLAnimatedImage +import Nuke final class SearchingTableViewCell: UITableViewCell { + let _imageView: UIImageView = { - let imageView = UIImageView() + let imageView = FLAnimatedImageView() imageView.tintColor = Asset.Colors.Label.primary.color imageView.layer.cornerRadius = 4 imageView.clipsToBounds = true @@ -37,8 +40,7 @@ final class SearchingTableViewCell: UITableViewCell { override func prepareForReuse() { super.prepareForReuse() - _imageView.af.cancelImageRequest() - _imageView.image = nil + Nuke.cancelRequest(for: _imageView) } override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { @@ -90,22 +92,28 @@ extension SearchingTableViewCell { } func config(with account: Mastodon.Entity.Account) { - _imageView.af.setImage( - withURL: URL(string: account.avatar)!, - placeholderImage: UIImage.placeholder(color: .systemFill), - imageTransition: .crossDissolve(0.2) + Nuke.loadImage( + with: account.avatarImageURL(), + options: ImageLoadingOptions( + placeholder: UIImage.placeholder(color: .systemFill), + transition: .fadeIn(duration: 0.2) + ), + into: _imageView ) _titleLabel.text = account.displayName.isEmpty ? account.username : account.displayName _subTitleLabel.text = account.acct } func config(with account: MastodonUser) { - _imageView.af.setImage( - withURL: URL(string: account.avatar)!, - placeholderImage: UIImage.placeholder(color: .systemFill), - imageTransition: .crossDissolve(0.2) + Nuke.loadImage( + with: account.avatarImageURL(), + options: ImageLoadingOptions( + placeholder: UIImage.placeholder(color: .systemFill), + transition: .fadeIn(duration: 0.2) + ), + into: _imageView ) - _titleLabel.text = account.displayName.isEmpty ? account.username : account.displayName + _titleLabel.text = account.displayNameWithFallback _subTitleLabel.text = account.acct } @@ -122,7 +130,7 @@ extension SearchingTableViewCell { let string = L10n.Scene.Search.Recommend.HashTag.peopleTalking(String(peopleAreTalking)) _subTitleLabel.text = string } - + func config(with tag: Tag) { let image = UIImage(systemName: "number.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 34, weight: .regular))!.withRenderingMode(.alwaysTemplate) _imageView.image = image diff --git a/Mastodon/Scene/Settings/SettingsViewController.swift b/Mastodon/Scene/Settings/SettingsViewController.swift index 89514a064..6a08bd45d 100644 --- a/Mastodon/Scene/Settings/SettingsViewController.swift +++ b/Mastodon/Scene/Settings/SettingsViewController.swift @@ -236,6 +236,8 @@ class SettingsViewController: UIViewController, NeedsDependency { return theme.secondarySystemBackgroundColor } }) + + tableView.separatorColor = theme.separator } private func setupNavigation() { @@ -356,7 +358,7 @@ extension SettingsViewController: UITableViewDelegate { case .appearance: // do nothing break - case .appearanceDarkMode: + case .appearanceDarkMode, .appearanceDisableAvatarAnimation: // do nothing break case .notification: @@ -443,10 +445,12 @@ extension SettingsViewController: SettingsToggleCellDelegate { func settingsToggleCell(_ cell: SettingsToggleTableViewCell, switchValueDidChange switch: UISwitch) { guard let dataSource = viewModel.dataSource else { return } guard let indexPath = tableView.indexPath(for: cell) else { return } + + let isOn = `switch`.isOn let item = dataSource.itemIdentifier(for: indexPath) + switch item { case .appearanceDarkMode(let settingObjectID): - let isOn = `switch`.isOn let managedObjectContext = context.backgroundManagedObjectContext managedObjectContext.performChanges { let setting = managedObjectContext.object(with: settingObjectID) as! Setting @@ -462,8 +466,23 @@ extension SettingsViewController: SettingsToggleCellDelegate { } } .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 isOn = `switch`.isOn let managedObjectContext = context.backgroundManagedObjectContext managedObjectContext.performChanges { let setting = managedObjectContext.object(with: settingObjectID) as! Setting diff --git a/Mastodon/Scene/Settings/SettingsViewModel.swift b/Mastodon/Scene/Settings/SettingsViewModel.swift index f212a591f..142de7dcf 100644 --- a/Mastodon/Scene/Settings/SettingsViewModel.swift +++ b/Mastodon/Scene/Settings/SettingsViewModel.swift @@ -96,7 +96,10 @@ extension SettingsViewModel { snapshot.appendSections([.appearance]) snapshot.appendItems(appearanceItems, toSection: .appearance) - let appearanceSettingItems = [SettingsItem.appearanceDarkMode(settingObjectID: setting.objectID)] + let appearanceSettingItems = [ + SettingsItem.appearanceDarkMode(settingObjectID: setting.objectID), + SettingsItem.appearanceDisableAvatarAnimation(settingObjectID: setting.objectID) + ] snapshot.appendSections([.appearanceSettings]) snapshot.appendItems(appearanceSettingItems, toSection: .appearanceSettings) @@ -146,7 +149,6 @@ extension SettingsViewModel { weak settingsToggleCellDelegate ] tableView, indexPath, item -> UITableViewCell? in guard let self = self else { return nil } - switch item { case .appearance(let objectID): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsAppearanceTableViewCell.self), for: indexPath) as! SettingsAppearanceTableViewCell @@ -167,13 +169,14 @@ extension SettingsViewModel { } cell.delegate = settingsAppearanceTableViewCellDelegate return cell - case .appearanceDarkMode(let objectID): + case .appearanceDarkMode(let objectID), + .appearanceDisableAvatarAnimation(let objectID): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsToggleTableViewCell.self), for: indexPath) as! SettingsToggleTableViewCell cell.delegate = settingsToggleCellDelegate - cell.textLabel?.text = L10n.Scene.Settings.Section.AppearanceSettings.DarkMode.title self.context.managedObjectContext.performAndWait { let setting = self.context.managedObjectContext.object(with: objectID) as! Setting - cell.switchButton.isOn = setting.preferredTrueBlackDarkMode + SettingsViewModel.configureSettingToggle(cell: cell, item: item, setting: setting) + ManagedObjectObserver.observe(object: setting) .receive(on: DispatchQueue.main) .sink(receiveCompletion: { _ in @@ -182,7 +185,7 @@ extension SettingsViewModel { guard let cell = cell else { return } guard case .update(let object) = change.changeType, let setting = object as? Setting else { return } - cell.switchButton.isOn = setting.preferredTrueBlackDarkMode + SettingsViewModel.configureSettingToggle(cell: cell, item: item, setting: setting) }) .store(in: &cell.disposeBag) } @@ -220,6 +223,23 @@ extension SettingsViewModel { } extension SettingsViewModel { + + static func configureSettingToggle( + cell: SettingsToggleTableViewCell, + item: SettingsItem, + setting: Setting + ) { + switch item { + case .appearanceDarkMode: + cell.textLabel?.text = L10n.Scene.Settings.Section.AppearanceSettings.DarkMode.title + cell.switchButton.isOn = setting.preferredTrueBlackDarkMode + case .appearanceDisableAvatarAnimation: + cell.textLabel?.text = L10n.Scene.Settings.Section.AppearanceSettings.AvatarAnimation.title + cell.switchButton.isOn = setting.preferredStaticAvatar + default: + assertionFailure() + } + } static func configureSettingToggle( cell: SettingsToggleTableViewCell, diff --git a/Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewController.swift b/Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewController.swift index 28b13e2fb..fb2d282af 100644 --- a/Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewController.swift +++ b/Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewController.swift @@ -8,8 +8,6 @@ import func AVFoundation.AVMakeRect import UIKit import Combine -import Nuke -import FLAnimatedImage final class ContextMenuImagePreviewViewController: UIViewController { @@ -17,19 +15,13 @@ final class ContextMenuImagePreviewViewController: UIViewController { var viewModel: ContextMenuImagePreviewViewModel! - var imageTask: ImageTask? let imageView: UIImageView = { - let imageView = FLAnimatedImageView() + let imageView = UIImageView() imageView.contentMode = .scaleAspectFill imageView.layer.masksToBounds = true return imageView }() - deinit { - imageTask?.cancel() - imageTask = nil - } - } extension ContextMenuImagePreviewViewController { @@ -55,13 +47,12 @@ extension ContextMenuImagePreviewViewController { .sink { [weak self] url in guard let self = self else { return } guard let url = url else { return } - self.imageTask = Nuke.loadImage( - with: url, - options: ImageLoadingOptions( - placeholder: self.viewModel.thumbnail, - transition: .fadeIn(duration: 0.2) - ), - into: self.imageView + self.imageView.af.setImage( + withURL: url, + placeholderImage: self.viewModel.thumbnail, + imageTransition: .crossDissolve(0.2), + runImageTransitionIfCached: true, + completion: nil ) } .store(in: &disposeBag) diff --git a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift index d60d40fe7..8336e852e 100644 --- a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift +++ b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift @@ -8,7 +8,6 @@ import os.log import func AVFoundation.AVMakeRect import UIKit -import Nuke protocol MosaicImageViewContainerPresentable: AnyObject { var mosaicImageViewContainer: MosaicImageViewContainer { get } @@ -24,8 +23,6 @@ final class MosaicImageViewContainer: UIView { weak var delegate: MosaicImageViewContainerDelegate? - var imageTasks = Set() - let container = UIStackView() private(set) lazy var imageViews: [UIImageView] = { (0..<4).map { _ -> UIImageView in @@ -94,11 +91,17 @@ extension MosaicImageViewContainer { } extension MosaicImageViewContainer { + + func resetImageTask() { + imageViews.forEach { imageView in + imageView.af.cancelImageRequest() + imageView.image = nil + } + } func reset() { - imageTasks.forEach { $0?.cancel() } - imageTasks.removeAll() - + resetImageTask() + container.arrangedSubviews.forEach { subview in container.removeArrangedSubview(subview) subview.removeFromSuperview() diff --git a/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift b/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift index 931932060..d5a457a26 100644 --- a/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift +++ b/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift @@ -17,6 +17,7 @@ protocol ContentWarningOverlayViewDelegate: AnyObject { class ContentWarningOverlayView: UIView { var disposeBag = Set() + private var _disposeBag = Set() static let cornerRadius: CGFloat = 4 static let blurVisualEffect = UIBlurEffect(style: .systemUltraThinMaterial) @@ -36,7 +37,6 @@ class ContentWarningOverlayView: UIView { // for status style overlay let contentOverlayView: UIView = { let view = UIView() - view.backgroundColor = ThemeService.shared.currentTheme.value.contentWarningOverlayBackgroundColor view.applyCornerRadius(radius: ContentWarningOverlayView.cornerRadius) return view }() @@ -156,6 +156,18 @@ extension ContentWarningOverlayView { addGestureRecognizer(tapGestureRecognizer) configure(style: .media) + 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) + } + + private func setupBackgroundColor(theme: Theme) { + contentOverlayView.backgroundColor = theme.contentWarningOverlayBackgroundColor } } diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index d813c2219..d62fba0df 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -95,10 +95,8 @@ final class StatusView: UIView { view.accessibilityLabel = L10n.Common.Controls.Status.showUserProfile return view }() - let avatarImageView: UIImageView = { + let avatarImageView: FLAnimatedImageView = { let imageView = FLAnimatedImageView() -// imageView.layer.shouldRasterize = true -// imageView.layer.rasterizationScale = UIScreen.main.scale return imageView }() let avatarStackedContainerButton: AvatarStackContainerButton = AvatarStackContainerButton() @@ -222,6 +220,7 @@ final class StatusView: UIView { metaText.textView.textContainer.lineFragmentPadding = 0 metaText.textView.textContainerInset = .zero metaText.textView.layer.masksToBounds = false + metaText.textView.textDragInteraction?.isEnabled = false // disable drag for link and attachment let paragraphStyle: NSMutableParagraphStyle = { let style = NSMutableParagraphStyle() @@ -480,6 +479,9 @@ extension StatusView { avatarStackedContainerButton.addTarget(self, action: #selector(StatusView.avatarStackedContainerButtonDidPressed(_:)), for: .touchUpInside) revealContentWarningButton.addTarget(self, action: #selector(StatusView.revealContentWarningButtonDidPressed(_:)), for: .touchUpInside) pollVoteButton.addTarget(self, action: #selector(StatusView.pollVoteButtonPressed(_:)), for: .touchUpInside) + + + } } @@ -582,6 +584,17 @@ extension StatusView: MetaTextViewDelegate { // MARK: - UITextViewDelegate extension StatusView: UITextViewDelegate { + + func textView(_ textView: UITextView, shouldInteractWith textAttachment: NSTextAttachment, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { + switch textView { + case contentMetaText.textView: + return false + default: + assertionFailure() + return true + } + } + func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { switch textView { case contentMetaText.textView: diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 27461cfc6..0b3d3fddb 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -74,7 +74,7 @@ final class StatusTableViewCell: UITableViewCell, StatusCell { override func prepareForReuse() { super.prepareForReuse() selectionStyle = .default - statusView.statusMosaicImageViewContainer.reset() + statusView.statusMosaicImageViewContainer.resetImageTask() statusView.contentMetaText.textView.isSelectable = false statusView.updateContentWarningDisplay(isHidden: true, animated: false) statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isUserInteractionEnabled = true diff --git a/Mastodon/Scene/Share/View/TableviewCell/ThreadReplyLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/ThreadReplyLoaderTableViewCell.swift index 9461bb282..ee9ac3438 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/ThreadReplyLoaderTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/ThreadReplyLoaderTableViewCell.swift @@ -18,11 +18,11 @@ final class ThreadReplyLoaderTableViewCell: UITableViewCell { static let cellHeight: CGFloat = 44 weak var delegate: ThreadReplyLoaderTableViewCellDelegate? + var _disposeBag = Set() let loadMoreButton: UIButton = { let button = HighlightDimmableButton() button.titleLabel?.font = TimelineLoaderTableViewCell.labelFont - button.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal) button.setTitle(L10n.Common.Controls.Timeline.Loader.showMoreReplies, for: .normal) return button @@ -83,6 +83,15 @@ extension ThreadReplyLoaderTableViewCell { resetSeparatorLineLayout() loadMoreButton.addTarget(self, action: #selector(ThreadReplyLoaderTableViewCell.loadMoreButtonDidPressed(_:)), 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) } private func resetSeparatorLineLayout() { @@ -113,6 +122,10 @@ extension ThreadReplyLoaderTableViewCell { } } } + + private func setupBackgroundColor(theme: Theme) { + loadMoreButton.backgroundColor = theme.secondarySystemGroupedBackgroundColor + } } diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift index 7a508fc75..e876041ca 100644 --- a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift @@ -49,18 +49,26 @@ final class SuggestionAccountViewModel: NSObject { super.init() - Publishers.CombineLatest(self.accounts,self.selectedAccounts) - .sink { [weak self] accounts,selectedAccounts in - self?.applyTableViewDataSource(accounts: accounts) - self?.applySelectedCollectionViewDataSource(accounts: selectedAccounts) - } - .store(in: &disposeBag) + Publishers.CombineLatest( + self.accounts, + self.selectedAccounts + ) + .receive(on: RunLoop.main) + .sink { [weak self] accounts,selectedAccounts in + self?.applyTableViewDataSource(accounts: accounts) + self?.applySelectedCollectionViewDataSource(accounts: selectedAccounts) + } + .store(in: &disposeBag) - Publishers.CombineLatest(self.selectedAccounts,self.headerPlaceholderCount) - .sink { [weak self] selectedAccount,count in - self?.applySelectedCollectionViewDataSource(accounts: selectedAccount) - } - .store(in: &disposeBag) + Publishers.CombineLatest( + self.selectedAccounts, + self.headerPlaceholderCount + ) + .receive(on: RunLoop.main) + .sink { [weak self] selectedAccount,count in + self?.applySelectedCollectionViewDataSource(accounts: selectedAccount) + } + .store(in: &disposeBag) viewWillAppear .sink { [weak self] _ in @@ -133,6 +141,7 @@ final class SuggestionAccountViewModel: NSObject { } func applyTableViewDataSource(accounts: [NSManagedObjectID]) { + assert(Thread.isMainThread) guard let dataSource = diffableDataSource else { return } var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) @@ -141,6 +150,7 @@ final class SuggestionAccountViewModel: NSObject { } func applySelectedCollectionViewDataSource(accounts: [NSManagedObjectID]) { + assert(Thread.isMainThread) guard let count = headerPlaceholderCount.value else { return } guard let dataSource = collectionDiffableDataSource else { return } var snapshot = NSDiffableDataSourceSnapshot() diff --git a/Mastodon/Service/APIService/APIService.swift b/Mastodon/Service/APIService/APIService.swift index b38e2e059..1316f2cf0 100644 --- a/Mastodon/Service/APIService/APIService.swift +++ b/Mastodon/Service/APIService/APIService.swift @@ -13,6 +13,7 @@ import CoreDataStack import MastodonSDK import AlamofireImage import AlamofireNetworkActivityIndicator +import Nuke final class APIService { @@ -34,6 +35,10 @@ final class APIService { // setup cache. 10MB RAM + 50MB Disk URLCache.shared = URLCache(memoryCapacity: 10 * 1024 * 1024, diskCapacity: 50 * 1024 * 1024, diskPath: nil) + + // setup Nuke cache + // using LRU disk cache + ImagePipeline.shared = ImagePipeline(configuration: .withDataCache) // enable network activity manager for AlamofireImage NetworkActivityIndicatorManager.shared.isEnabled = true diff --git a/Mastodon/Service/AuthenticationService.swift b/Mastodon/Service/AuthenticationService.swift index a0bbca57a..4a460b1d0 100644 --- a/Mastodon/Service/AuthenticationService.swift +++ b/Mastodon/Service/AuthenticationService.swift @@ -81,6 +81,26 @@ final class AuthenticationService: NSObject { .assign(to: \.value, on: activeMastodonAuthenticationBox) .store(in: &disposeBag) + activeMastodonAuthenticationBox + .receive(on: RunLoop.main) + .sink { [weak self] authenticationBox in + guard let _ = self else { return } + guard let authenticationBox = authenticationBox else { return } + let request = Setting.sortedFetchRequest + request.predicate = Setting.predicate(domain: authenticationBox.domain, userID: authenticationBox.userID) + guard let setting = managedObjectContext.safeFetch(request).first else { return } + + let themeName: ThemeName = setting.preferredTrueBlackDarkMode ? .system : .mastodon + if UserDefaults.shared.currentThemeNameRawValue != themeName.rawValue { + ThemeService.shared.set(themeName: themeName) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update theme style", ((#file as NSString).lastPathComponent), #line, #function) + } + if UserDefaults.shared.preferredStaticAvatar != setting.preferredStaticAvatar { + UserDefaults.shared.preferredStaticAvatar = setting.preferredStaticAvatar + } + } + .store(in: &disposeBag) + do { try mastodonAuthenticationFetchedResultsController.performFetch() mastodonAuthentications.value = mastodonAuthenticationFetchedResultsController.fetchedObjects ?? [] diff --git a/Mastodon/Service/PhotoLibraryService.swift b/Mastodon/Service/PhotoLibraryService.swift index 45b47a836..53e7529c0 100644 --- a/Mastodon/Service/PhotoLibraryService.swift +++ b/Mastodon/Service/PhotoLibraryService.swift @@ -9,7 +9,7 @@ import os.log import UIKit import Combine import Photos -import Nuke +import AlamofireImage final class PhotoLibraryService: NSObject { @@ -49,28 +49,29 @@ extension PhotoLibraryService { let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) let notificationFeedbackGenerator = UINotificationFeedbackGenerator() - return ImagePipeline.shared.imagePublisher(with: url) - .handleEvents(receiveSubscription: { _ in - impactFeedbackGenerator.impactOccurred() - }, receiveOutput: { response in - self.save(image: response.image) - }, receiveCompletion: { completion in - switch completion { + return Future { promise in + ImageDownloader.default.download(URLRequest(url: url), completion: { response in + switch response.result { case .failure(let error): os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: download image %s fail: %s", ((#file as NSString).lastPathComponent), #line, #function, url.debugDescription, error.localizedDescription) - - notificationFeedbackGenerator.notificationOccurred(.error) - case .finished: + promise(.failure(error)) + case .success(let image): os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: download image %s success", ((#file as NSString).lastPathComponent), #line, #function, url.debugDescription) - - notificationFeedbackGenerator.notificationOccurred(.success) + promise(.success(image)) } }) - .map { response in - return response.image + } + .handleEvents(receiveSubscription: { _ in + impactFeedbackGenerator.impactOccurred() + }, receiveCompletion: { completion in + switch completion { + case .failure: + notificationFeedbackGenerator.notificationOccurred(.error) + case .finished: + notificationFeedbackGenerator.notificationOccurred(.success) } - .mapError { error in error as Error } - .eraseToAnyPublisher() + }) + .eraseToAnyPublisher() } func save(image: UIImage, withNotificationFeedback: Bool = false) { diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift index 4e09e4939..e65ea9aca 100644 --- a/NotificationService/NotificationService.swift +++ b/NotificationService/NotificationService.swift @@ -56,7 +56,7 @@ class NotificationService: UNNotificationServiceExtension { bestAttemptContent.title = notification.title bestAttemptContent.subtitle = "" - bestAttemptContent.body = notification.body + bestAttemptContent.body = notification.body.escape() bestAttemptContent.sound = UNNotificationSound.init(named: UNNotificationSoundName(rawValue: "BoopSound.caf")) bestAttemptContent.userInfo["plaintext"] = plaintextData @@ -105,3 +105,16 @@ extension NotificationService { return try? P256.KeyAgreement.PublicKey(x963Representation: publicKeyData) } } + +extension String { + func escape() -> String { + return self + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: """, with: "\"") + .replacingOccurrences(of: "'", with: "'") + .replacingOccurrences(of: "'", with: "’") + + } +}