Merge pull request #210 from tootsuite/fix/feedback
Fix issues from user feedback
This commit is contained in:
commit
5dbe5d6d7d
|
@ -189,6 +189,7 @@
|
|||
<attribute name="appearanceRaw" attributeType="String"/>
|
||||
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="domain" attributeType="String"/>
|
||||
<attribute name="preferredStaticAvatar" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="preferredTrueBlackDarkMode" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="userID" attributeType="String"/>
|
||||
|
@ -281,7 +282,7 @@
|
|||
<element name="PollOption" positionX="0" positionY="0" width="128" height="134"/>
|
||||
<element name="PrivateNote" positionX="0" positionY="0" width="128" height="89"/>
|
||||
<element name="SearchHistory" positionX="0" positionY="0" width="128" height="104"/>
|
||||
<element name="Setting" positionX="72" positionY="162" width="128" height="134"/>
|
||||
<element name="Setting" positionX="72" positionY="162" width="128" height="149"/>
|
||||
<element name="Status" positionX="0" positionY="0" width="128" height="599"/>
|
||||
<element name="Subscription" positionX="81" positionY="171" width="128" height="179"/>
|
||||
<element name="SubscriptionAlerts" positionX="72" positionY="162" width="128" height="14"/>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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 = "<group>"; };
|
||||
DB9D6C3725E508BE0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = "<group>"; };
|
||||
DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIInterpolatingMotionEffect.swift; sourceTree = "<group>"; };
|
||||
DBA088DE26958164003EB4B2 /* UserFetchedResultsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserFetchedResultsController.swift; sourceTree = "<group>"; };
|
||||
DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedEdgesButton.swift; sourceTree = "<group>"; };
|
||||
DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = "<group>"; };
|
||||
DBA1DB7F268F84F80052DB59 /* NotificationType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationType.swift; sourceTree = "<group>"; };
|
||||
|
@ -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" */ = {
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
<key>CoreDataStack.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>21</integer>
|
||||
<integer>20</integer>
|
||||
</dict>
|
||||
<key>Mastodon - ASDK.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
|
@ -37,7 +37,7 @@
|
|||
<key>NotificationService.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>20</integer>
|
||||
<integer>21</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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<AnyCancellable>()
|
||||
|
||||
let fetchedResultsController: NSFetchedResultsController<MastodonUser>
|
||||
|
||||
// input
|
||||
let domain = CurrentValueSubject<String?, Never>(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<NSFetchRequestResult>, 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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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")!
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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")!
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
}()
|
||||
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -40,6 +40,8 @@ final class SearchViewModel: NSObject {
|
|||
var accountDiffableDataSource: UICollectionViewDiffableDataSource<RecommendAccountSection, NSManagedObjectID>?
|
||||
var searchResultDiffableDataSource: UITableViewDiffableDataSource<SearchResultSection, SearchResultItem>?
|
||||
|
||||
let statusFetchedResultsController: StatusFetchedResultsController
|
||||
|
||||
// bottom loader
|
||||
private(set) lazy var loadoldestStateMachine: GKStateMachine = {
|
||||
// exclude timeline middle fetcher state
|
||||
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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<ImageTask?>()
|
||||
|
||||
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()
|
||||
|
|
|
@ -17,6 +17,7 @@ protocol ContentWarningOverlayViewDelegate: AnyObject {
|
|||
class ContentWarningOverlayView: UIView {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
private var _disposeBag = Set<AnyCancellable>()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -18,11 +18,11 @@ final class ThreadReplyLoaderTableViewCell: UITableViewCell {
|
|||
static let cellHeight: CGFloat = 44
|
||||
|
||||
weak var delegate: ThreadReplyLoaderTableViewCellDelegate?
|
||||
var _disposeBag = Set<AnyCancellable>()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -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<RecommendAccountSection, NSManagedObjectID>()
|
||||
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<SelectedAccountSection, SelectedAccountItem>()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 ?? []
|
||||
|
|
|
@ -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<UIImage, Error> { 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) {
|
||||
|
|
|
@ -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: "’")
|
||||
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue