Merge branch 'release/0.8.7' into main

This commit is contained in:
CMK 2021-07-08 11:40:55 +08:00
commit 4961a98479
40 changed files with 467 additions and 214 deletions

View File

@ -189,6 +189,7 @@
<attribute name="appearanceRaw" attributeType="String"/> <attribute name="appearanceRaw" attributeType="String"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/> <attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String"/> <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="preferredTrueBlackDarkMode" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/> <attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="userID" attributeType="String"/> <attribute name="userID" attributeType="String"/>
@ -281,7 +282,7 @@
<element name="PollOption" positionX="0" positionY="0" width="128" height="134"/> <element name="PollOption" positionX="0" positionY="0" width="128" height="134"/>
<element name="PrivateNote" positionX="0" positionY="0" width="128" height="89"/> <element name="PrivateNote" positionX="0" positionY="0" width="128" height="89"/>
<element name="SearchHistory" positionX="0" positionY="0" width="128" height="104"/> <element name="SearchHistory" positionX="0" positionY="0" width="128" height="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="Status" positionX="0" positionY="0" width="128" height="599"/>
<element name="Subscription" positionX="81" positionY="171" width="128" height="179"/> <element name="Subscription" positionX="81" positionY="171" width="128" height="179"/>
<element name="SubscriptionAlerts" positionX="72" positionY="162" width="128" height="14"/> <element name="SubscriptionAlerts" positionX="72" positionY="162" width="128" height="14"/>

View File

@ -10,10 +10,12 @@ import Foundation
public final class Setting: NSManagedObject { public final class Setting: NSManagedObject {
@NSManaged public var appearanceRaw: String
@NSManaged public var preferredTrueBlackDarkMode: Bool
@NSManaged public var domain: String @NSManaged public var domain: String
@NSManaged public var userID: 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 createdAt: Date
@NSManaged public private(set) var updatedAt: Date @NSManaged public private(set) var updatedAt: Date
@ -54,6 +56,12 @@ extension Setting {
self.preferredTrueBlackDarkMode = preferredTrueBlackDarkMode self.preferredTrueBlackDarkMode = preferredTrueBlackDarkMode
didUpdate(at: Date()) 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) { public func didUpdate(at networkDate: Date) {
self.updatedAt = networkDate self.updatedAt = networkDate

View File

@ -200,7 +200,7 @@
"slogan": "Social networking\nback in your hands." "slogan": "Social networking\nback in your hands."
}, },
"server_picker": { "server_picker": {
"title": "Pick a Server,\nany server.", "title": "Pick a server,\nany server.",
"button": { "button": {
"category": { "category": {
"all": "All", "all": "All",
@ -497,6 +497,9 @@
"appearance_settings": { "appearance_settings": {
"dark_mode": { "dark_mode": {
"title": "True black Dark Mode" "title": "True black Dark Mode"
},
"avatar_animation": {
"title": "Disable avatar animation"
} }
}, },
"notifications": { "notifications": {

View File

@ -411,6 +411,7 @@
DB9D6C2E25E504AC0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2D25E504AC0051B173 /* Attachment.swift */; }; DB9D6C2E25E504AC0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2D25E504AC0051B173 /* Attachment.swift */; };
DB9D6C3825E508BE0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C3725E508BE0051B173 /* Attachment.swift */; }; DB9D6C3825E508BE0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C3725E508BE0051B173 /* Attachment.swift */; };
DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.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 */; }; DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */; };
DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */; }; DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */; };
DBA1DB80268F84F80052DB59 /* NotificationType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA1DB7F268F84F80052DB59 /* NotificationType.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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; DBA1DB7F268F84F80052DB59 /* NotificationType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationType.swift; sourceTree = "<group>"; };
@ -2607,6 +2609,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */, DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */,
DBA088DE26958164003EB4B2 /* UserFetchedResultsController.swift */,
DB6D9F75263587C7008423CD /* SettingFetchedResultController.swift */, DB6D9F75263587C7008423CD /* SettingFetchedResultController.swift */,
); );
path = FetchedResultsController; path = FetchedResultsController;
@ -3256,6 +3259,7 @@
DBA9443E265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift in Sources */, DBA9443E265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift in Sources */,
2D24E1232626ED9D00A59D4F /* UIView+Gesture.swift in Sources */, 2D24E1232626ED9D00A59D4F /* UIView+Gesture.swift in Sources */,
DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */, DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */,
DBA088DF26958164003EB4B2 /* UserFetchedResultsController.swift in Sources */,
DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */, DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */,
0F202213261351F5000C64BF /* APIService+HashtagTimeline.swift in Sources */, 0F202213261351F5000C64BF /* APIService+HashtagTimeline.swift in Sources */,
DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */, DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */,
@ -3894,7 +3898,7 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 31; CURRENT_PROJECT_VERSION = 32;
DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets";
DEVELOPMENT_TEAM = 5Z4GVSS33P; DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = Mastodon/Info.plist; INFOPLIST_FILE = Mastodon/Info.plist;
@ -3902,7 +3906,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.8.6; MARKETING_VERSION = 0.8.7;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app; PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@ -3921,7 +3925,7 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 31; CURRENT_PROJECT_VERSION = 32;
DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets";
DEVELOPMENT_TEAM = 5Z4GVSS33P; DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = Mastodon/Info.plist; INFOPLIST_FILE = Mastodon/Info.plist;
@ -3929,7 +3933,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.8.6; MARKETING_VERSION = 0.8.7;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app; PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@ -4249,7 +4253,7 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 31; CURRENT_PROJECT_VERSION = 32;
DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets";
DEVELOPMENT_TEAM = 5Z4GVSS33P; DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = Mastodon/Info.plist; INFOPLIST_FILE = Mastodon/Info.plist;
@ -4257,7 +4261,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.8.6; MARKETING_VERSION = 0.8.7;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app; PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@ -4363,7 +4367,7 @@
buildSettings = { buildSettings = {
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 31; CURRENT_PROJECT_VERSION = 32;
DEVELOPMENT_TEAM = 5Z4GVSS33P; DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = NotificationService/Info.plist; INFOPLIST_FILE = NotificationService/Info.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@ -4371,7 +4375,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 0.8.6; MARKETING_VERSION = 0.8.7;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService; PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@ -4482,7 +4486,7 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 31; CURRENT_PROJECT_VERSION = 32;
DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets";
DEVELOPMENT_TEAM = 5Z4GVSS33P; DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = Mastodon/Info.plist; INFOPLIST_FILE = Mastodon/Info.plist;
@ -4490,7 +4494,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.8.6; MARKETING_VERSION = 0.8.7;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app; PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@ -4596,7 +4600,7 @@
buildSettings = { buildSettings = {
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 31; CURRENT_PROJECT_VERSION = 32;
DEVELOPMENT_TEAM = 5Z4GVSS33P; DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = NotificationService/Info.plist; INFOPLIST_FILE = NotificationService/Info.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@ -4604,7 +4608,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 0.8.6; MARKETING_VERSION = 0.8.7;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService; PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@ -4650,7 +4654,7 @@
buildSettings = { buildSettings = {
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 31; CURRENT_PROJECT_VERSION = 32;
DEVELOPMENT_TEAM = 5Z4GVSS33P; DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = NotificationService/Info.plist; INFOPLIST_FILE = NotificationService/Info.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@ -4658,7 +4662,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 0.8.6; MARKETING_VERSION = 0.8.7;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService; PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@ -4673,7 +4677,7 @@
buildSettings = { buildSettings = {
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 31; CURRENT_PROJECT_VERSION = 32;
DEVELOPMENT_TEAM = 5Z4GVSS33P; DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = NotificationService/Info.plist; INFOPLIST_FILE = NotificationService/Info.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@ -4681,7 +4685,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 0.8.6; MARKETING_VERSION = 0.8.7;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService; PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@ -4829,7 +4833,7 @@
repositoryURL = "https://github.com/TwidereProject/MetaTextView.git"; repositoryURL = "https://github.com/TwidereProject/MetaTextView.git";
requirement = { requirement = {
kind = exactVersion; kind = exactVersion;
version = 1.2.5; version = 1.3.0;
}; };
}; };
DB0E2D2C26833FF600865C3C /* XCRemoteSwiftPackageReference "Nuke-FLAnimatedImage-Plugin" */ = { DB0E2D2C26833FF600865C3C /* XCRemoteSwiftPackageReference "Nuke-FLAnimatedImage-Plugin" */ = {

View File

@ -114,8 +114,8 @@
"repositoryURL": "https://github.com/TwidereProject/MetaTextView.git", "repositoryURL": "https://github.com/TwidereProject/MetaTextView.git",
"state": { "state": {
"branch": null, "branch": null,
"revision": "9ba4027ed0a88185ce95bb1773620c2ceaa9f3bb", "revision": "e2049e14ef411c6810d53c1baf553b5161c6678f",
"version": "1.2.5" "version": "1.3.0"
} }
}, },
{ {

View File

@ -141,7 +141,11 @@ extension SceneCoordinator {
if let navigationControllerVisibleViewController = presentingViewController.navigationController?.visibleViewController { if let navigationControllerVisibleViewController = presentingViewController.navigationController?.visibleViewController {
switch viewController { switch viewController {
case is ProfileViewController: 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 barButtonItem.tintColor = .white
navigationControllerVisibleViewController.navigationItem.backBarButtonItem = barButtonItem navigationControllerVisibleViewController.navigationItem.backBarButtonItem = barButtonItem
default: default:

View File

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

View File

@ -11,6 +11,7 @@ import CoreData
enum SettingsItem: Hashable { enum SettingsItem: Hashable {
case appearance(settingObjectID: NSManagedObjectID) case appearance(settingObjectID: NSManagedObjectID)
case appearanceDarkMode(settingObjectID: NSManagedObjectID) case appearanceDarkMode(settingObjectID: NSManagedObjectID)
case appearanceDisableAvatarAnimation(settingObjectID: NSManagedObjectID)
case notification(settingObjectID: NSManagedObjectID, switchMode: NotificationSwitchMode) case notification(settingObjectID: NSManagedObjectID, switchMode: NotificationSwitchMode)
case boringZone(item: Link) case boringZone(item: Link)
case spicyZone(item: Link) case spicyZone(item: Link)

View File

@ -68,7 +68,7 @@ extension PollSection {
cell.pollOptionView.checkmarkImageView.isHidden = true cell.pollOptionView.checkmarkImageView.isHidden = true
case .off: case .off:
ThemeService.shared.currentTheme ThemeService.shared.currentTheme
.receive(on: RunLoop.main) .receive(on: DispatchQueue.main)
.sink { [weak cell] theme in .sink { [weak cell] theme in
guard let cell = cell else { return } guard let cell = cell else { return }
cell.pollOptionView.checkmarkBackgroundView.backgroundColor = theme.tertiarySystemBackgroundColor cell.pollOptionView.checkmarkBackgroundView.backgroundColor = theme.tertiarySystemBackgroundColor
@ -80,7 +80,7 @@ extension PollSection {
cell.pollOptionView.checkmarkImageView.isHidden = true cell.pollOptionView.checkmarkImageView.isHidden = true
case .on: case .on:
ThemeService.shared.currentTheme ThemeService.shared.currentTheme
.receive(on: RunLoop.main) .receive(on: DispatchQueue.main)
.sink { [weak cell] theme in .sink { [weak cell] theme in
guard let cell = cell else { return } guard let cell = cell else { return }
cell.pollOptionView.checkmarkBackgroundView.backgroundColor = theme.tertiarySystemBackgroundColor cell.pollOptionView.checkmarkBackgroundView.backgroundColor = theme.tertiarySystemBackgroundColor

View File

@ -39,7 +39,7 @@ extension ProfileFieldSection {
.sink { [weak cell] name, emojiDict in .sink { [weak cell] name, emojiDict in
guard let cell = cell else { return } guard let cell = cell else { return }
cell.fieldView.titleActiveLabel.configure(field: name, emojiDict: emojiDict) 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) .store(in: &cell.disposeBag)
@ -55,7 +55,7 @@ extension ProfileFieldSection {
.sink { [weak cell] value, emojiDict in .sink { [weak cell] value, emojiDict in
guard let cell = cell else { return } guard let cell = cell else { return }
cell.fieldView.valueActiveLabel.configure(field: value, emojiDict: emojiDict) 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) .store(in: &cell.disposeBag)

View File

@ -11,7 +11,7 @@ import CoreDataStack
import os.log import os.log
import UIKit import UIKit
import AVKit import AVKit
import Nuke import AlamofireImage
import MastodonMeta import MastodonMeta
// import LinkPresentation // import LinkPresentation
@ -137,6 +137,9 @@ extension StatusSection {
switch item { switch item {
case .root: case .root:
// allow select content
cell.statusView.contentMetaText.textView.isSelectable = true
// configure thread meta
StatusSection.configureThreadMeta(cell: cell, status: status) StatusSection.configureThreadMeta(cell: cell, status: status)
ManagedObjectObserver.observe(object: status.reblog ?? status) ManagedObjectObserver.observe(object: status.reblog ?? status)
.receive(on: RunLoop.main) .receive(on: RunLoop.main)
@ -519,13 +522,7 @@ extension StatusSection {
let name = author.displayName.isEmpty ? author.username : author.displayName let name = author.displayName.isEmpty ? author.username : author.displayName
return L10n.Common.Controls.Status.userRepliedTo(name) return L10n.Common.Controls.Status.userRepliedTo(name)
}() }()
MastodonStatusContent.parseResult(content: headerText, emojiDict: status.replyTo?.author.emojiDict ?? [:]) cell.statusView.headerInfoLabel.configure(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.accessibilityLabel = headerText cell.statusView.headerInfoLabel.accessibilityLabel = headerText
cell.statusView.headerInfoLabel.isAccessibilityElement = status.replyTo != nil cell.statusView.headerInfoLabel.isAccessibilityElement = status.replyTo != nil
} else { } else {
@ -541,13 +538,7 @@ extension StatusSection {
// name // name
let author = (status.reblog ?? status).author let author = (status.reblog ?? status).author
let nameContent = author.displayNameWithFallback let nameContent = author.displayNameWithFallback
MastodonStatusContent.parseResult(content: nameContent, emojiDict: author.emojiDict) cell.statusView.nameLabel.configure(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.accessibilityLabel = nameContent cell.statusView.nameLabel.accessibilityLabel = nameContent
// username // username
cell.statusView.usernameLabel.text = "@" + author.acct cell.statusView.usernameLabel.text = "@" + author.acct
@ -648,48 +639,32 @@ extension StatusSection {
} }
.store(in: &cell.disposeBag) .store(in: &cell.disposeBag)
let isSingleMosaicLayout = mosaics.count == 1
// set image // set image
let imageSize = CGSize( let url: URL = {
width: mosaic.imageViewSize.width * imageView.traitCollection.displayScale,
height: mosaic.imageViewSize.height * imageView.traitCollection.displayScale
)
let url: URL? = {
if UIDevice.current.userInterfaceIdiom == .phone { if UIDevice.current.userInterfaceIdiom == .phone {
return meta.previewURL ?? meta.url return meta.previewURL ?? meta.url
} }
return 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( // let imageSize = CGSize(
with: request, // width: mosaic.imageViewSize.width * imageView.traitCollection.displayScale,
options: options, // height: mosaic.imageViewSize.height * imageView.traitCollection.displayScale
into: imageView // )
) { result in // let imageFilter = AspectScaledToFillSizeFilter(size: imageSize)
switch result {
case .failure: imageView.af.setImage(
break withURL: url,
placeholderImage: UIImage.placeholder(color: .systemFill),
imageTransition: .crossDissolve(0.2)
) { response in
switch response.result {
case .success: case .success:
statusItemAttribute.isImageLoaded.value = true statusItemAttribute.isImageLoaded.value = true
case .failure:
break
} }
}? }
.store(in: &cell.statusView.statusMosaicImageViewContainer.imageTasks)
imageView.accessibilityLabel = meta.altText imageView.accessibilityLabel = meta.altText

View File

@ -74,11 +74,12 @@ extension MastodonUser {
} }
public func avatarImageURL() -> URL? { 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 { 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")!
} }
} }

View File

@ -5,6 +5,7 @@
// Created by xiaojian sun on 2021/4/2. // Created by xiaojian sun on 2021/4/2.
// //
import UIKit
import MastodonSDK import MastodonSDK
extension Mastodon.Entity.Account: Hashable { extension Mastodon.Entity.Account: Hashable {
@ -16,3 +17,14 @@ extension Mastodon.Entity.Account: Hashable {
return lhs.id == rhs.id 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")!
}
}

View File

@ -830,7 +830,7 @@ internal enum L10n {
} }
} }
internal enum ServerPicker { internal enum ServerPicker {
/// Pick a Server,\nany server. /// Pick a server,\nany server.
internal static let title = L10n.tr("Localizable", "Scene.ServerPicker.Title") internal static let title = L10n.tr("Localizable", "Scene.ServerPicker.Title")
internal enum Button { internal enum Button {
/// See Less /// See Less
@ -934,6 +934,10 @@ internal enum L10n {
internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Appearance.Title") internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Appearance.Title")
} }
internal enum AppearanceSettings { 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 { internal enum DarkMode {
/// True black Dark Mode /// True black Dark Mode
internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.AppearanceSettings.DarkMode.Title") internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.AppearanceSettings.DarkMode.Title")

View File

@ -17,4 +17,12 @@ extension UserDefaults {
set { self[#function] = newValue.rawValue } set { self[#function] = newValue.rawValue }
} }
@objc dynamic var preferredStaticAvatar: Bool {
get {
register(defaults: [#function: false])
return bool(forKey: #function)
}
set { self[#function] = newValue }
}
} }

View File

@ -210,6 +210,40 @@ extension UserProviderFacade {
) -> UIMenu { ) -> UIMenu {
var children: [UIMenuElement] = [] var children: [UIMenuElement] = []
let name = mastodonUser.displayNameWithFallback 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 { if !isMyself {
// mute // 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 { 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) { 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 [weak provider] _ in

View File

@ -23,9 +23,9 @@
"color-space" : "srgb", "color-space" : "srgb",
"components" : { "components" : {
"alpha" : "1.000", "alpha" : "1.000",
"blue" : "0.263", "blue" : "0x6E",
"green" : "0.208", "green" : "0x57",
"red" : "0.192" "red" : "0x4F"
} }
}, },
"idiom" : "universal" "idiom" : "universal"

View File

@ -23,9 +23,9 @@
"color-space" : "srgb", "color-space" : "srgb",
"components" : { "components" : {
"alpha" : "1.000", "alpha" : "1.000",
"blue" : "0.263", "blue" : "60",
"green" : "0.208", "green" : "58",
"red" : "0.192" "red" : "58"
} }
}, },
"idiom" : "universal" "idiom" : "universal"

View File

@ -303,7 +303,7 @@ tap the link to confirm your account.";
"Scene.ServerPicker.Label.Category" = "CATEGORY"; "Scene.ServerPicker.Label.Category" = "CATEGORY";
"Scene.ServerPicker.Label.Language" = "LANGUAGE"; "Scene.ServerPicker.Label.Language" = "LANGUAGE";
"Scene.ServerPicker.Label.Users" = "USERS"; "Scene.ServerPicker.Label.Users" = "USERS";
"Scene.ServerPicker.Title" = "Pick a Server, "Scene.ServerPicker.Title" = "Pick a server,
any server."; any server.";
"Scene.ServerRules.Button.Confirm" = "I Agree"; "Scene.ServerRules.Button.Confirm" = "I Agree";
"Scene.ServerRules.PrivacyPolicy" = "privacy policy"; "Scene.ServerRules.PrivacyPolicy" = "privacy policy";
@ -317,6 +317,7 @@ any server.";
"Scene.Settings.Section.Appearance.Dark" = "Always Dark"; "Scene.Settings.Section.Appearance.Dark" = "Always Dark";
"Scene.Settings.Section.Appearance.Light" = "Always Light"; "Scene.Settings.Section.Appearance.Light" = "Always Light";
"Scene.Settings.Section.Appearance.Title" = "Appearance"; "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.AppearanceSettings.DarkMode.Title" = "True black Dark Mode";
"Scene.Settings.Section.Boringzone.Privacy" = "Privacy Policy"; "Scene.Settings.Section.Boringzone.Privacy" = "Privacy Policy";
"Scene.Settings.Section.Boringzone.Terms" = "Terms of Service"; "Scene.Settings.Section.Boringzone.Terms" = "Terms of Service";

View File

@ -303,7 +303,7 @@ tap the link to confirm your account.";
"Scene.ServerPicker.Label.Category" = "CATEGORY"; "Scene.ServerPicker.Label.Category" = "CATEGORY";
"Scene.ServerPicker.Label.Language" = "LANGUAGE"; "Scene.ServerPicker.Label.Language" = "LANGUAGE";
"Scene.ServerPicker.Label.Users" = "USERS"; "Scene.ServerPicker.Label.Users" = "USERS";
"Scene.ServerPicker.Title" = "Pick a Server, "Scene.ServerPicker.Title" = "Pick a server,
any server."; any server.";
"Scene.ServerRules.Button.Confirm" = "I Agree"; "Scene.ServerRules.Button.Confirm" = "I Agree";
"Scene.ServerRules.PrivacyPolicy" = "privacy policy"; "Scene.ServerRules.PrivacyPolicy" = "privacy policy";
@ -317,6 +317,7 @@ any server.";
"Scene.Settings.Section.Appearance.Dark" = "Always Dark"; "Scene.Settings.Section.Appearance.Dark" = "Always Dark";
"Scene.Settings.Section.Appearance.Light" = "Always Light"; "Scene.Settings.Section.Appearance.Light" = "Always Light";
"Scene.Settings.Section.Appearance.Title" = "Appearance"; "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.AppearanceSettings.DarkMode.Title" = "True black Dark Mode";
"Scene.Settings.Section.Boringzone.Privacy" = "Privacy Policy"; "Scene.Settings.Section.Boringzone.Privacy" = "Privacy Policy";
"Scene.Settings.Section.Boringzone.Terms" = "Terms of Service"; "Scene.Settings.Section.Boringzone.Terms" = "Terms of Service";

View File

@ -205,10 +205,6 @@ extension HomeTimelineViewController {
// needs trigger manually after onboarding dismiss // needs trigger manually after onboarding dismiss
setNeedsStatusBarAppearanceUpdate() setNeedsStatusBarAppearanceUpdate()
if (viewModel.fetchedResultsController.fetchedObjects ?? []).isEmpty {
viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.Loading.self)
}
} }
override func viewDidAppear(_ animated: Bool) { override func viewDidAppear(_ animated: Bool) {
@ -216,11 +212,10 @@ extension HomeTimelineViewController {
viewModel.viewDidAppear.send() viewModel.viewDidAppear.send()
DispatchQueue.main.async { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
guard let self = self else { return } guard let self = self else { return }
if (self.viewModel.fetchedResultsController.fetchedObjects ?? []).count == 0 { // always try to refresh timeline after appear
self.viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.Loading.self) self.viewModel.homeTimelineNeedRefresh.send()
}
} }
} }

View File

@ -135,8 +135,10 @@ final class HomeTimelineViewModel: NSObject {
self?.loadLatestStateMachine.enter(LoadLatestState.Loading.self) self?.loadLatestStateMachine.enter(LoadLatestState.Loading.self)
} }
.store(in: &disposeBag) .store(in: &disposeBag)
// refresh after publish post
homeTimelineNavigationBarTitleViewModel.isPublished homeTimelineNavigationBarTitleViewModel.isPublished
.delay(for: 2, scheduler: DispatchQueue.main)
.sink { [weak self] isPublished in .sink { [weak self] isPublished in
guard let self = self else { return } guard let self = self else { return }
self.homeTimelineNeedRefresh.send() self.homeTimelineNeedRefresh.send()

View File

@ -209,7 +209,12 @@ extension ProfileHeaderViewController {
.sink { [weak self] isEditing, note, editingNote in .sink { [weak self] isEditing, note, editingNote in
guard let self = self else { return } guard let self = self else { return }
self.profileHeaderView.bioActiveLabel.configure(note: note ?? "", emojiDict: [:]) // FIXME: custom emoji 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) .store(in: &disposeBag)

View File

@ -37,6 +37,7 @@ final class ProfileFieldView: UIView {
let valueActiveLabel: ActiveLabel = { let valueActiveLabel: ActiveLabel = {
let label = ActiveLabel(style: .profileFieldValue) let label = ActiveLabel(style: .profileFieldValue)
label.configure(content: "value", emojiDict: [:]) label.configure(content: "value", emojiDict: [:])
label.textAlignment = .right
return label return label
}() }()

View File

@ -152,7 +152,11 @@ extension ProfileViewController {
.store(in: &disposeBag) .store(in: &disposeBag)
let barAppearance = UINavigationBarAppearance() let barAppearance = UINavigationBarAppearance()
barAppearance.configureWithTransparentBackground() if isModal {
barAppearance.configureWithDefaultBackground()
} else {
barAppearance.configureWithTransparentBackground()
}
navigationItem.standardAppearance = barAppearance navigationItem.standardAppearance = barAppearance
navigationItem.compactAppearance = barAppearance navigationItem.compactAppearance = barAppearance
navigationItem.scrollEdgeAppearance = barAppearance navigationItem.scrollEdgeAppearance = barAppearance
@ -228,10 +232,10 @@ extension ProfileViewController {
overlayScrollView.refreshControl = refreshControl overlayScrollView.refreshControl = refreshControl
refreshControl.addTarget(self, action: #selector(ProfileViewController.refreshControlValueChanged(_:)), for: .valueChanged) 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) 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) bind(userTimelineViewModel: repliesUserTimelineViewModel)
let mediaUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter(onlyMedia: true)) let mediaUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter(onlyMedia: true))

View File

@ -40,6 +40,8 @@ final class SearchViewModel: NSObject {
var accountDiffableDataSource: UICollectionViewDiffableDataSource<RecommendAccountSection, NSManagedObjectID>? var accountDiffableDataSource: UICollectionViewDiffableDataSource<RecommendAccountSection, NSManagedObjectID>?
var searchResultDiffableDataSource: UITableViewDiffableDataSource<SearchResultSection, SearchResultItem>? var searchResultDiffableDataSource: UITableViewDiffableDataSource<SearchResultSection, SearchResultItem>?
let statusFetchedResultsController: StatusFetchedResultsController
// bottom loader // bottom loader
private(set) lazy var loadoldestStateMachine: GKStateMachine = { private(set) lazy var loadoldestStateMachine: GKStateMachine = {
// exclude timeline middle fetcher state // exclude timeline middle fetcher state
@ -59,6 +61,11 @@ final class SearchViewModel: NSObject {
init(context: AppContext, coordinator: SceneCoordinator) { init(context: AppContext, coordinator: SceneCoordinator) {
self.coordinator = coordinator self.coordinator = coordinator
self.context = context self.context = context
self.statusFetchedResultsController = StatusFetchedResultsController(
managedObjectContext: context.managedObjectContext,
domain: nil,
additionalTweetPredicate: nil
)
super.init() super.init()
// bind active authentication // bind active authentication
@ -70,6 +77,7 @@ final class SearchViewModel: NSObject {
return return
} }
self.currentMastodonUser.value = activeMastodonAuthentication.user self.currentMastodonUser.value = activeMastodonAuthentication.user
self.statusFetchedResultsController.domain.value = activeMastodonAuthentication.domain
} }
.store(in: &disposeBag) .store(in: &disposeBag)
@ -224,7 +232,7 @@ final class SearchViewModel: NSObject {
} }
} }
.store(in: &disposeBag) .store(in: &disposeBag)
searchResult searchResult
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] searchResult in .sink { [weak self] searchResult in
@ -343,7 +351,7 @@ final class SearchViewModel: NSObject {
DispatchQueue.main.async { DispatchQueue.main.async {
self.coordinator.present(scene: .profile(viewModel: viewModel), from: from, transition: .show) self.coordinator.present(scene: .profile(viewModel: viewModel), from: from, transition: .show)
} }
case .hashtag(let tag): case .hashtag(let tag):
let (tagInCoreData, _) = APIService.CoreData.createOrMergeTag(into: self.context.managedObjectContext, entity: tag) let (tagInCoreData, _) = APIService.CoreData.createOrMergeTag(into: self.context.managedObjectContext, entity: tag)
if let searchHistories = searchHistories { if let searchHistories = searchHistories {

View File

@ -10,10 +10,13 @@ import CoreDataStack
import Foundation import Foundation
import MastodonSDK import MastodonSDK
import UIKit import UIKit
import FLAnimatedImage
import Nuke
final class SearchingTableViewCell: UITableViewCell { final class SearchingTableViewCell: UITableViewCell {
let _imageView: UIImageView = { let _imageView: UIImageView = {
let imageView = UIImageView() let imageView = FLAnimatedImageView()
imageView.tintColor = Asset.Colors.Label.primary.color imageView.tintColor = Asset.Colors.Label.primary.color
imageView.layer.cornerRadius = 4 imageView.layer.cornerRadius = 4
imageView.clipsToBounds = true imageView.clipsToBounds = true
@ -37,8 +40,7 @@ final class SearchingTableViewCell: UITableViewCell {
override func prepareForReuse() { override func prepareForReuse() {
super.prepareForReuse() super.prepareForReuse()
_imageView.af.cancelImageRequest() Nuke.cancelRequest(for: _imageView)
_imageView.image = nil
} }
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
@ -90,22 +92,28 @@ extension SearchingTableViewCell {
} }
func config(with account: Mastodon.Entity.Account) { func config(with account: Mastodon.Entity.Account) {
_imageView.af.setImage( Nuke.loadImage(
withURL: URL(string: account.avatar)!, with: account.avatarImageURL(),
placeholderImage: UIImage.placeholder(color: .systemFill), options: ImageLoadingOptions(
imageTransition: .crossDissolve(0.2) placeholder: UIImage.placeholder(color: .systemFill),
transition: .fadeIn(duration: 0.2)
),
into: _imageView
) )
_titleLabel.text = account.displayName.isEmpty ? account.username : account.displayName _titleLabel.text = account.displayName.isEmpty ? account.username : account.displayName
_subTitleLabel.text = account.acct _subTitleLabel.text = account.acct
} }
func config(with account: MastodonUser) { func config(with account: MastodonUser) {
_imageView.af.setImage( Nuke.loadImage(
withURL: URL(string: account.avatar)!, with: account.avatarImageURL(),
placeholderImage: UIImage.placeholder(color: .systemFill), options: ImageLoadingOptions(
imageTransition: .crossDissolve(0.2) 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 _subTitleLabel.text = account.acct
} }
@ -122,7 +130,7 @@ extension SearchingTableViewCell {
let string = L10n.Scene.Search.Recommend.HashTag.peopleTalking(String(peopleAreTalking)) let string = L10n.Scene.Search.Recommend.HashTag.peopleTalking(String(peopleAreTalking))
_subTitleLabel.text = string _subTitleLabel.text = string
} }
func config(with tag: Tag) { func config(with tag: Tag) {
let image = UIImage(systemName: "number.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 34, weight: .regular))!.withRenderingMode(.alwaysTemplate) let image = UIImage(systemName: "number.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 34, weight: .regular))!.withRenderingMode(.alwaysTemplate)
_imageView.image = image _imageView.image = image

View File

@ -236,6 +236,8 @@ class SettingsViewController: UIViewController, NeedsDependency {
return theme.secondarySystemBackgroundColor return theme.secondarySystemBackgroundColor
} }
}) })
tableView.separatorColor = theme.separator
} }
private func setupNavigation() { private func setupNavigation() {
@ -356,7 +358,7 @@ extension SettingsViewController: UITableViewDelegate {
case .appearance: case .appearance:
// do nothing // do nothing
break break
case .appearanceDarkMode: case .appearanceDarkMode, .appearanceDisableAvatarAnimation:
// do nothing // do nothing
break break
case .notification: case .notification:
@ -443,10 +445,12 @@ extension SettingsViewController: SettingsToggleCellDelegate {
func settingsToggleCell(_ cell: SettingsToggleTableViewCell, switchValueDidChange switch: UISwitch) { func settingsToggleCell(_ cell: SettingsToggleTableViewCell, switchValueDidChange switch: UISwitch) {
guard let dataSource = viewModel.dataSource else { return } guard let dataSource = viewModel.dataSource else { return }
guard let indexPath = tableView.indexPath(for: cell) else { return } guard let indexPath = tableView.indexPath(for: cell) else { return }
let isOn = `switch`.isOn
let item = dataSource.itemIdentifier(for: indexPath) let item = dataSource.itemIdentifier(for: indexPath)
switch item { switch item {
case .appearanceDarkMode(let settingObjectID): case .appearanceDarkMode(let settingObjectID):
let isOn = `switch`.isOn
let managedObjectContext = context.backgroundManagedObjectContext let managedObjectContext = context.backgroundManagedObjectContext
managedObjectContext.performChanges { managedObjectContext.performChanges {
let setting = managedObjectContext.object(with: settingObjectID) as! Setting let setting = managedObjectContext.object(with: settingObjectID) as! Setting
@ -462,8 +466,23 @@ extension SettingsViewController: SettingsToggleCellDelegate {
} }
} }
.store(in: &disposeBag) .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): case .notification(let settingObjectID, let switchMode):
let isOn = `switch`.isOn
let managedObjectContext = context.backgroundManagedObjectContext let managedObjectContext = context.backgroundManagedObjectContext
managedObjectContext.performChanges { managedObjectContext.performChanges {
let setting = managedObjectContext.object(with: settingObjectID) as! Setting let setting = managedObjectContext.object(with: settingObjectID) as! Setting

View File

@ -96,7 +96,10 @@ extension SettingsViewModel {
snapshot.appendSections([.appearance]) snapshot.appendSections([.appearance])
snapshot.appendItems(appearanceItems, toSection: .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.appendSections([.appearanceSettings])
snapshot.appendItems(appearanceSettingItems, toSection: .appearanceSettings) snapshot.appendItems(appearanceSettingItems, toSection: .appearanceSettings)
@ -146,7 +149,6 @@ extension SettingsViewModel {
weak settingsToggleCellDelegate weak settingsToggleCellDelegate
] tableView, indexPath, item -> UITableViewCell? in ] tableView, indexPath, item -> UITableViewCell? in
guard let self = self else { return nil } guard let self = self else { return nil }
switch item { switch item {
case .appearance(let objectID): case .appearance(let objectID):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsAppearanceTableViewCell.self), for: indexPath) as! SettingsAppearanceTableViewCell let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsAppearanceTableViewCell.self), for: indexPath) as! SettingsAppearanceTableViewCell
@ -167,13 +169,14 @@ extension SettingsViewModel {
} }
cell.delegate = settingsAppearanceTableViewCellDelegate cell.delegate = settingsAppearanceTableViewCellDelegate
return cell 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 let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsToggleTableViewCell.self), for: indexPath) as! SettingsToggleTableViewCell
cell.delegate = settingsToggleCellDelegate cell.delegate = settingsToggleCellDelegate
cell.textLabel?.text = L10n.Scene.Settings.Section.AppearanceSettings.DarkMode.title
self.context.managedObjectContext.performAndWait { self.context.managedObjectContext.performAndWait {
let setting = self.context.managedObjectContext.object(with: objectID) as! Setting 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) ManagedObjectObserver.observe(object: setting)
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink(receiveCompletion: { _ in .sink(receiveCompletion: { _ in
@ -182,7 +185,7 @@ extension SettingsViewModel {
guard let cell = cell else { return } guard let cell = cell else { return }
guard case .update(let object) = change.changeType, guard case .update(let object) = change.changeType,
let setting = object as? Setting else { return } let setting = object as? Setting else { return }
cell.switchButton.isOn = setting.preferredTrueBlackDarkMode SettingsViewModel.configureSettingToggle(cell: cell, item: item, setting: setting)
}) })
.store(in: &cell.disposeBag) .store(in: &cell.disposeBag)
} }
@ -220,6 +223,23 @@ extension SettingsViewModel {
} }
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( static func configureSettingToggle(
cell: SettingsToggleTableViewCell, cell: SettingsToggleTableViewCell,

View File

@ -8,8 +8,6 @@
import func AVFoundation.AVMakeRect import func AVFoundation.AVMakeRect
import UIKit import UIKit
import Combine import Combine
import Nuke
import FLAnimatedImage
final class ContextMenuImagePreviewViewController: UIViewController { final class ContextMenuImagePreviewViewController: UIViewController {
@ -17,19 +15,13 @@ final class ContextMenuImagePreviewViewController: UIViewController {
var viewModel: ContextMenuImagePreviewViewModel! var viewModel: ContextMenuImagePreviewViewModel!
var imageTask: ImageTask?
let imageView: UIImageView = { let imageView: UIImageView = {
let imageView = FLAnimatedImageView() let imageView = UIImageView()
imageView.contentMode = .scaleAspectFill imageView.contentMode = .scaleAspectFill
imageView.layer.masksToBounds = true imageView.layer.masksToBounds = true
return imageView return imageView
}() }()
deinit {
imageTask?.cancel()
imageTask = nil
}
} }
extension ContextMenuImagePreviewViewController { extension ContextMenuImagePreviewViewController {
@ -55,13 +47,12 @@ extension ContextMenuImagePreviewViewController {
.sink { [weak self] url in .sink { [weak self] url in
guard let self = self else { return } guard let self = self else { return }
guard let url = url else { return } guard let url = url else { return }
self.imageTask = Nuke.loadImage( self.imageView.af.setImage(
with: url, withURL: url,
options: ImageLoadingOptions( placeholderImage: self.viewModel.thumbnail,
placeholder: self.viewModel.thumbnail, imageTransition: .crossDissolve(0.2),
transition: .fadeIn(duration: 0.2) runImageTransitionIfCached: true,
), completion: nil
into: self.imageView
) )
} }
.store(in: &disposeBag) .store(in: &disposeBag)

View File

@ -8,7 +8,6 @@
import os.log import os.log
import func AVFoundation.AVMakeRect import func AVFoundation.AVMakeRect
import UIKit import UIKit
import Nuke
protocol MosaicImageViewContainerPresentable: AnyObject { protocol MosaicImageViewContainerPresentable: AnyObject {
var mosaicImageViewContainer: MosaicImageViewContainer { get } var mosaicImageViewContainer: MosaicImageViewContainer { get }
@ -24,8 +23,6 @@ final class MosaicImageViewContainer: UIView {
weak var delegate: MosaicImageViewContainerDelegate? weak var delegate: MosaicImageViewContainerDelegate?
var imageTasks = Set<ImageTask?>()
let container = UIStackView() let container = UIStackView()
private(set) lazy var imageViews: [UIImageView] = { private(set) lazy var imageViews: [UIImageView] = {
(0..<4).map { _ -> UIImageView in (0..<4).map { _ -> UIImageView in
@ -94,11 +91,17 @@ extension MosaicImageViewContainer {
} }
extension MosaicImageViewContainer { extension MosaicImageViewContainer {
func resetImageTask() {
imageViews.forEach { imageView in
imageView.af.cancelImageRequest()
imageView.image = nil
}
}
func reset() { func reset() {
imageTasks.forEach { $0?.cancel() } resetImageTask()
imageTasks.removeAll()
container.arrangedSubviews.forEach { subview in container.arrangedSubviews.forEach { subview in
container.removeArrangedSubview(subview) container.removeArrangedSubview(subview)
subview.removeFromSuperview() subview.removeFromSuperview()

View File

@ -17,6 +17,7 @@ protocol ContentWarningOverlayViewDelegate: AnyObject {
class ContentWarningOverlayView: UIView { class ContentWarningOverlayView: UIView {
var disposeBag = Set<AnyCancellable>() var disposeBag = Set<AnyCancellable>()
private var _disposeBag = Set<AnyCancellable>()
static let cornerRadius: CGFloat = 4 static let cornerRadius: CGFloat = 4
static let blurVisualEffect = UIBlurEffect(style: .systemUltraThinMaterial) static let blurVisualEffect = UIBlurEffect(style: .systemUltraThinMaterial)
@ -36,7 +37,6 @@ class ContentWarningOverlayView: UIView {
// for status style overlay // for status style overlay
let contentOverlayView: UIView = { let contentOverlayView: UIView = {
let view = UIView() let view = UIView()
view.backgroundColor = ThemeService.shared.currentTheme.value.contentWarningOverlayBackgroundColor
view.applyCornerRadius(radius: ContentWarningOverlayView.cornerRadius) view.applyCornerRadius(radius: ContentWarningOverlayView.cornerRadius)
return view return view
}() }()
@ -156,6 +156,18 @@ extension ContentWarningOverlayView {
addGestureRecognizer(tapGestureRecognizer) addGestureRecognizer(tapGestureRecognizer)
configure(style: .media) 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
} }
} }

View File

@ -95,10 +95,8 @@ final class StatusView: UIView {
view.accessibilityLabel = L10n.Common.Controls.Status.showUserProfile view.accessibilityLabel = L10n.Common.Controls.Status.showUserProfile
return view return view
}() }()
let avatarImageView: UIImageView = { let avatarImageView: FLAnimatedImageView = {
let imageView = FLAnimatedImageView() let imageView = FLAnimatedImageView()
// imageView.layer.shouldRasterize = true
// imageView.layer.rasterizationScale = UIScreen.main.scale
return imageView return imageView
}() }()
let avatarStackedContainerButton: AvatarStackContainerButton = AvatarStackContainerButton() let avatarStackedContainerButton: AvatarStackContainerButton = AvatarStackContainerButton()
@ -222,6 +220,7 @@ final class StatusView: UIView {
metaText.textView.textContainer.lineFragmentPadding = 0 metaText.textView.textContainer.lineFragmentPadding = 0
metaText.textView.textContainerInset = .zero metaText.textView.textContainerInset = .zero
metaText.textView.layer.masksToBounds = false metaText.textView.layer.masksToBounds = false
metaText.textView.textDragInteraction?.isEnabled = false // disable drag for link and attachment
let paragraphStyle: NSMutableParagraphStyle = { let paragraphStyle: NSMutableParagraphStyle = {
let style = NSMutableParagraphStyle() let style = NSMutableParagraphStyle()
@ -480,6 +479,9 @@ extension StatusView {
avatarStackedContainerButton.addTarget(self, action: #selector(StatusView.avatarStackedContainerButtonDidPressed(_:)), for: .touchUpInside) avatarStackedContainerButton.addTarget(self, action: #selector(StatusView.avatarStackedContainerButtonDidPressed(_:)), for: .touchUpInside)
revealContentWarningButton.addTarget(self, action: #selector(StatusView.revealContentWarningButtonDidPressed(_:)), for: .touchUpInside) revealContentWarningButton.addTarget(self, action: #selector(StatusView.revealContentWarningButtonDidPressed(_:)), for: .touchUpInside)
pollVoteButton.addTarget(self, action: #selector(StatusView.pollVoteButtonPressed(_:)), for: .touchUpInside) pollVoteButton.addTarget(self, action: #selector(StatusView.pollVoteButtonPressed(_:)), for: .touchUpInside)
} }
} }
@ -582,6 +584,17 @@ extension StatusView: MetaTextViewDelegate {
// MARK: - UITextViewDelegate // MARK: - UITextViewDelegate
extension StatusView: 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 { func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
switch textView { switch textView {
case contentMetaText.textView: case contentMetaText.textView:

View File

@ -74,7 +74,7 @@ final class StatusTableViewCell: UITableViewCell, StatusCell {
override func prepareForReuse() { override func prepareForReuse() {
super.prepareForReuse() super.prepareForReuse()
selectionStyle = .default selectionStyle = .default
statusView.statusMosaicImageViewContainer.reset() statusView.statusMosaicImageViewContainer.resetImageTask()
statusView.contentMetaText.textView.isSelectable = false statusView.contentMetaText.textView.isSelectable = false
statusView.updateContentWarningDisplay(isHidden: true, animated: false) statusView.updateContentWarningDisplay(isHidden: true, animated: false)
statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isUserInteractionEnabled = true statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isUserInteractionEnabled = true

View File

@ -18,11 +18,11 @@ final class ThreadReplyLoaderTableViewCell: UITableViewCell {
static let cellHeight: CGFloat = 44 static let cellHeight: CGFloat = 44
weak var delegate: ThreadReplyLoaderTableViewCellDelegate? weak var delegate: ThreadReplyLoaderTableViewCellDelegate?
var _disposeBag = Set<AnyCancellable>()
let loadMoreButton: UIButton = { let loadMoreButton: UIButton = {
let button = HighlightDimmableButton() let button = HighlightDimmableButton()
button.titleLabel?.font = TimelineLoaderTableViewCell.labelFont button.titleLabel?.font = TimelineLoaderTableViewCell.labelFont
button.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color
button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal) button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal)
button.setTitle(L10n.Common.Controls.Timeline.Loader.showMoreReplies, for: .normal) button.setTitle(L10n.Common.Controls.Timeline.Loader.showMoreReplies, for: .normal)
return button return button
@ -83,6 +83,15 @@ extension ThreadReplyLoaderTableViewCell {
resetSeparatorLineLayout() resetSeparatorLineLayout()
loadMoreButton.addTarget(self, action: #selector(ThreadReplyLoaderTableViewCell.loadMoreButtonDidPressed(_:)), for: .touchUpInside) 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() { private func resetSeparatorLineLayout() {
@ -113,6 +122,10 @@ extension ThreadReplyLoaderTableViewCell {
} }
} }
} }
private func setupBackgroundColor(theme: Theme) {
loadMoreButton.backgroundColor = theme.secondarySystemGroupedBackgroundColor
}
} }

View File

@ -49,18 +49,26 @@ final class SuggestionAccountViewModel: NSObject {
super.init() super.init()
Publishers.CombineLatest(self.accounts,self.selectedAccounts) Publishers.CombineLatest(
.sink { [weak self] accounts,selectedAccounts in self.accounts,
self?.applyTableViewDataSource(accounts: accounts) self.selectedAccounts
self?.applySelectedCollectionViewDataSource(accounts: selectedAccounts) )
} .receive(on: RunLoop.main)
.store(in: &disposeBag) .sink { [weak self] accounts,selectedAccounts in
self?.applyTableViewDataSource(accounts: accounts)
self?.applySelectedCollectionViewDataSource(accounts: selectedAccounts)
}
.store(in: &disposeBag)
Publishers.CombineLatest(self.selectedAccounts,self.headerPlaceholderCount) Publishers.CombineLatest(
.sink { [weak self] selectedAccount,count in self.selectedAccounts,
self?.applySelectedCollectionViewDataSource(accounts: selectedAccount) self.headerPlaceholderCount
} )
.store(in: &disposeBag) .receive(on: RunLoop.main)
.sink { [weak self] selectedAccount,count in
self?.applySelectedCollectionViewDataSource(accounts: selectedAccount)
}
.store(in: &disposeBag)
viewWillAppear viewWillAppear
.sink { [weak self] _ in .sink { [weak self] _ in
@ -133,6 +141,7 @@ final class SuggestionAccountViewModel: NSObject {
} }
func applyTableViewDataSource(accounts: [NSManagedObjectID]) { func applyTableViewDataSource(accounts: [NSManagedObjectID]) {
assert(Thread.isMainThread)
guard let dataSource = diffableDataSource else { return } guard let dataSource = diffableDataSource else { return }
var snapshot = NSDiffableDataSourceSnapshot<RecommendAccountSection, NSManagedObjectID>() var snapshot = NSDiffableDataSourceSnapshot<RecommendAccountSection, NSManagedObjectID>()
snapshot.appendSections([.main]) snapshot.appendSections([.main])
@ -141,6 +150,7 @@ final class SuggestionAccountViewModel: NSObject {
} }
func applySelectedCollectionViewDataSource(accounts: [NSManagedObjectID]) { func applySelectedCollectionViewDataSource(accounts: [NSManagedObjectID]) {
assert(Thread.isMainThread)
guard let count = headerPlaceholderCount.value else { return } guard let count = headerPlaceholderCount.value else { return }
guard let dataSource = collectionDiffableDataSource else { return } guard let dataSource = collectionDiffableDataSource else { return }
var snapshot = NSDiffableDataSourceSnapshot<SelectedAccountSection, SelectedAccountItem>() var snapshot = NSDiffableDataSourceSnapshot<SelectedAccountSection, SelectedAccountItem>()

View File

@ -13,6 +13,7 @@ import CoreDataStack
import MastodonSDK import MastodonSDK
import AlamofireImage import AlamofireImage
import AlamofireNetworkActivityIndicator import AlamofireNetworkActivityIndicator
import Nuke
final class APIService { final class APIService {
@ -34,6 +35,10 @@ final class APIService {
// setup cache. 10MB RAM + 50MB Disk // setup cache. 10MB RAM + 50MB Disk
URLCache.shared = URLCache(memoryCapacity: 10 * 1024 * 1024, diskCapacity: 50 * 1024 * 1024, diskPath: nil) 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 // enable network activity manager for AlamofireImage
NetworkActivityIndicatorManager.shared.isEnabled = true NetworkActivityIndicatorManager.shared.isEnabled = true

View File

@ -81,6 +81,26 @@ final class AuthenticationService: NSObject {
.assign(to: \.value, on: activeMastodonAuthenticationBox) .assign(to: \.value, on: activeMastodonAuthenticationBox)
.store(in: &disposeBag) .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 { do {
try mastodonAuthenticationFetchedResultsController.performFetch() try mastodonAuthenticationFetchedResultsController.performFetch()
mastodonAuthentications.value = mastodonAuthenticationFetchedResultsController.fetchedObjects ?? [] mastodonAuthentications.value = mastodonAuthenticationFetchedResultsController.fetchedObjects ?? []

View File

@ -9,7 +9,7 @@ import os.log
import UIKit import UIKit
import Combine import Combine
import Photos import Photos
import Nuke import AlamofireImage
final class PhotoLibraryService: NSObject { final class PhotoLibraryService: NSObject {
@ -49,28 +49,29 @@ extension PhotoLibraryService {
let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light)
let notificationFeedbackGenerator = UINotificationFeedbackGenerator() let notificationFeedbackGenerator = UINotificationFeedbackGenerator()
return ImagePipeline.shared.imagePublisher(with: url) return Future<UIImage, Error> { promise in
.handleEvents(receiveSubscription: { _ in ImageDownloader.default.download(URLRequest(url: url), completion: { response in
impactFeedbackGenerator.impactOccurred() switch response.result {
}, receiveOutput: { response in
self.save(image: response.image)
}, receiveCompletion: { completion in
switch completion {
case .failure(let error): 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) 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)
promise(.failure(error))
notificationFeedbackGenerator.notificationOccurred(.error) case .success(let image):
case .finished:
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: download image %s success", ((#file as NSString).lastPathComponent), #line, #function, url.debugDescription) os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: download image %s success", ((#file as NSString).lastPathComponent), #line, #function, url.debugDescription)
promise(.success(image))
notificationFeedbackGenerator.notificationOccurred(.success)
} }
}) })
.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) { func save(image: UIImage, withNotificationFeedback: Bool = false) {

View File

@ -56,7 +56,7 @@ class NotificationService: UNNotificationServiceExtension {
bestAttemptContent.title = notification.title bestAttemptContent.title = notification.title
bestAttemptContent.subtitle = "" bestAttemptContent.subtitle = ""
bestAttemptContent.body = notification.body bestAttemptContent.body = notification.body.escape()
bestAttemptContent.sound = UNNotificationSound.init(named: UNNotificationSoundName(rawValue: "BoopSound.caf")) bestAttemptContent.sound = UNNotificationSound.init(named: UNNotificationSoundName(rawValue: "BoopSound.caf"))
bestAttemptContent.userInfo["plaintext"] = plaintextData bestAttemptContent.userInfo["plaintext"] = plaintextData
@ -105,3 +105,16 @@ extension NotificationService {
return try? P256.KeyAgreement.PublicKey(x963Representation: publicKeyData) return try? P256.KeyAgreement.PublicKey(x963Representation: publicKeyData)
} }
} }
extension String {
func escape() -> String {
return self
.replacingOccurrences(of: "&amp;", with: "&")
.replacingOccurrences(of: "&lt;", with: "<")
.replacingOccurrences(of: "&gt;", with: ">")
.replacingOccurrences(of: "&quot;", with: "\"")
.replacingOccurrences(of: "&apos;", with: "'")
.replacingOccurrences(of: "&#39;", with: "")
}
}