Merge branch 'release/0.6.3'

This commit is contained in:
CMK 2021-06-17 20:00:52 +08:00
commit 7bdf4a5940
37 changed files with 456 additions and 204 deletions

View File

@ -27,7 +27,7 @@ public final class Attachment: NSManagedObject {
@NSManaged public private(set) var updatedAt: Date
@NSManaged public private(set) var index: NSNumber
// many-to-one relastionship
// many-to-one relationship
@NSManaged public private(set) var status: Status?
}

View File

@ -174,7 +174,8 @@
},
"timeline": {
"timestamp": {
"now": "Now"
"now": "Now",
"time_ago": "%s ago"
},
"loader": {
"load_missing_posts": "Load missing posts",

View File

@ -417,6 +417,7 @@
DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3FAE26172FC0004B8251 /* RemoteProfileViewModel.swift */; };
DBAEDE5C267A058D00D25FF5 /* BlurhashImageCacheService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAEDE5B267A058D00D25FF5 /* BlurhashImageCacheService.swift */; };
DBAEDE5F267A0B1500D25FF5 /* Nuke in Frameworks */ = {isa = PBXBuildFile; productRef = DBAEDE5E267A0B1500D25FF5 /* Nuke */; };
DBAEDE61267B342D00D25FF5 /* StatusContentCacheService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAEDE60267B342D00D25FF5 /* StatusContentCacheService.swift */; };
DBAFB7352645463500371D5F /* Emojis.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAFB7342645463500371D5F /* Emojis.swift */; };
DBB525082611EAC0002F1F29 /* Tabman in Frameworks */ = {isa = PBXBuildFile; productRef = DBB525072611EAC0002F1F29 /* Tabman */; };
DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */; };
@ -989,6 +990,7 @@
DBAE3FA82617106E004B8251 /* MastodonMetricFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonMetricFormatter.swift; sourceTree = "<group>"; };
DBAE3FAE26172FC0004B8251 /* RemoteProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteProfileViewModel.swift; sourceTree = "<group>"; };
DBAEDE5B267A058D00D25FF5 /* BlurhashImageCacheService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurhashImageCacheService.swift; sourceTree = "<group>"; };
DBAEDE60267B342D00D25FF5 /* StatusContentCacheService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentCacheService.swift; sourceTree = "<group>"; };
DBAFB7342645463500371D5F /* Emojis.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emojis.swift; sourceTree = "<group>"; };
DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSegmentedViewController.swift; sourceTree = "<group>"; };
DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewController.swift; sourceTree = "<group>"; };
@ -1358,6 +1360,7 @@
DBA5E7A2263AD0A3004598BB /* PhotoLibraryService.swift */,
DB297B1A2679FAE200704C90 /* PlaceholderImageCacheService.swift */,
DBAEDE5B267A058D00D25FF5 /* BlurhashImageCacheService.swift */,
DBAEDE60267B342D00D25FF5 /* StatusContentCacheService.swift */,
);
path = Service;
sourceTree = "<group>";
@ -2985,6 +2988,7 @@
DB92CF7225E7BB98002C1017 /* PollOptionTableViewCell.swift in Sources */,
DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */,
2D364F7225E66D7500204FDC /* MastodonResendEmailViewController.swift in Sources */,
DBAEDE61267B342D00D25FF5 /* StatusContentCacheService.swift in Sources */,
2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */,
DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */,
DB02CDAB26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift in Sources */,
@ -3626,7 +3630,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 12;
CURRENT_PROJECT_VERSION = 13;
DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets";
DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = Mastodon/Info.plist;
@ -3634,7 +3638,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.6.2;
MARKETING_VERSION = 0.6.3;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -3653,7 +3657,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 12;
CURRENT_PROJECT_VERSION = 13;
DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets";
DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = Mastodon/Info.plist;
@ -3661,7 +3665,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.6.2;
MARKETING_VERSION = 0.6.3;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -3916,7 +3920,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 12;
CURRENT_PROJECT_VERSION = 13;
DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = NotificationService/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
@ -3924,7 +3928,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 0.6.2;
MARKETING_VERSION = 0.6.3;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@ -3939,7 +3943,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 12;
CURRENT_PROJECT_VERSION = 13;
DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = NotificationService/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
@ -3947,7 +3951,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 0.6.2;
MARKETING_VERSION = 0.6.3;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;

View File

@ -12,12 +12,12 @@
<key>CoreDataStack.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>15</integer>
<integer>16</integer>
</dict>
<key>Mastodon - RTL.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>2</integer>
<integer>1</integer>
</dict>
<key>Mastodon - Release.xcscheme_^#shared#^_</key>
<dict>
@ -27,12 +27,12 @@
<key>Mastodon.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>12</integer>
<integer>3</integer>
</dict>
<key>NotificationService.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>16</integer>
<integer>17</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>

View File

@ -194,9 +194,15 @@ extension StatusSection {
let author = (status.reblog ?? status).author
return author.displayName.isEmpty ? author.username : author.displayName
}()
cell.statusView.nameLabel.configure(content: nameText, emojiDict: (status.reblog ?? status).author.emojiDict)
MastodonStatusContent.parseResult(content: nameText, emojiDict: (status.reblog ?? status).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.usernameLabel.text = "@" + (status.reblog ?? status).author.acct
// set avatar
if let reblog = status.reblog {
cell.statusView.avatarButton.isHidden = true
@ -210,6 +216,19 @@ extension StatusSection {
}
// set text
// func configureStatusContent() {
// let content = (status.reblog ?? status).content
// let emojiDict = (status.reblog ?? status).emojiDict
// if let cachedParseResult = AppContext.shared.statusContentCacheService.parseResult(content: content, emojiDict: emojiDict) {
// cell.statusView.activeTextLabel.configure(contentParseResult: cachedParseResult)
// } else {
// cell.statusView.activeTextLabel.configure(
// content: (status.reblog ?? status).content,
// emojiDict: (status.reblog ?? status).emojiDict
// )
// }
// }
// configureStatusContent()
cell.statusView.activeTextLabel.configure(
content: (status.reblog ?? status).content,
emojiDict: (status.reblog ?? status).emojiDict
@ -221,7 +240,7 @@ extension StatusSection {
cell.statusView.updateVisibility(visibility: visibility)
cell.statusView.revealContentWarningButton.publisher(for: \.isHidden)
.receive(on: RunLoop.main)
.receive(on: DispatchQueue.main)
.sink { [weak cell] isHidden in
cell?.statusView.visibilityImageView.isHidden = !isHidden
}
@ -251,7 +270,7 @@ extension StatusSection {
default: return 0.7
}
}()
return CGSize(width: maxWidth, height: maxWidth * scale)
return CGSize(width: maxWidth, height: floor(maxWidth * scale))
}()
let mosaics: [MosaicImageViewContainer.ConfigurableMosaic] = {
if mosaicImageViewModel.metas.count == 1 {
@ -268,13 +287,16 @@ extension StatusSection {
let blurhashOverlayImageView = mosaic.blurhashOverlayImageView
let meta = mosaicImageViewModel.metas[i]
// set blurhash image
meta.blurhashImagePublisher()
.receive(on: RunLoop.main)
.sink { image in
blurhashOverlayImageView.image = image
}
.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
@ -282,12 +304,18 @@ extension StatusSection {
let request = ImageRequest(
url: meta.url,
processors: [
ImageProcessors.Resize(size: imageSize, contentMode: .aspectFill)
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,
@ -300,24 +328,13 @@ extension StatusSection {
statusItemAttribute.isImageLoaded.value = true
}
}
//imageView.af.setImage(
// withURL: meta.url,
// placeholderImage: UIImage.placeholder(color: .systemFill),
// imageTransition: .crossDissolve(0.2)
//) { response in
// switch response.result {
// case .success:
// statusItemAttribute.isImageLoaded.value = true
// case .failure:
// break
// }
//}
imageView.accessibilityLabel = meta.altText
Publishers.CombineLatest(
statusItemAttribute.isImageLoaded,
statusItemAttribute.isRevealing
)
.receive(on: RunLoop.main)
.receive(on: DispatchQueue.main) // needs call immediately
.sink { [weak cell] isImageLoaded, isMediaRevealing in
guard let cell = cell else { return }
guard isImageLoaded else {
@ -358,12 +375,11 @@ extension StatusSection {
return containerFrame.width
}()
let scale: CGFloat = 1.3
return CGSize(width: maxWidth, height: maxWidth * scale)
return CGSize(width: maxWidth, height: floor(maxWidth * scale))
}()
if let videoAttachment = mediaAttachments.filter({ $0.type == .gifv || $0.type == .video }).first,
let videoPlayerViewModel = dependency.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: videoAttachment)
{
let videoPlayerViewModel = dependency.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: videoAttachment) {
var parent: UIViewController?
var playerViewControllerDelegate: AVPlayerViewControllerDelegate? = nil
switch cell {
@ -391,24 +407,35 @@ extension StatusSection {
playerViewController.player = videoPlayerViewModel.player
playerViewController.showsPlaybackControls = videoPlayerViewModel.videoKind != .gif
playerContainerView.setMediaKind(kind: videoPlayerViewModel.videoKind)
if videoPlayerViewModel.videoKind == .gif {
switch videoPlayerViewModel.videoKind {
case .gif:
playerContainerView.setMediaIndicator(isHidden: false)
} else {
videoPlayerViewModel.timeControlStatus.sink { timeControlStatus in
UIView.animate(withDuration: 0.33) {
switch timeControlStatus {
case .playing:
playerContainerView.setMediaIndicator(isHidden: true)
case .paused, .waitingToPlayAtSpecifiedRate:
playerContainerView.setMediaIndicator(isHidden: false)
@unknown default:
assertionFailure()
}
}
case .video:
playerContainerView.setMediaIndicator(isHidden: true)
}
playerContainerView.isHidden = false
// set blurhash overlay
playerContainerView.isReadyForDisplay
.receive(on: DispatchQueue.main)
.sink { [weak playerContainerView] isReadyForDisplay in
guard let playerContainerView = playerContainerView else { return }
playerContainerView.blurhashOverlayImageView.alpha = isReadyForDisplay ? 0 : 1
}
.store(in: &cell.disposeBag)
if let blurhash = videoAttachment.blurhash,
let url = URL(string: videoAttachment.url) {
AppContext.shared.blurhashImageCacheService.image(
blurhash: blurhash,
size: playerContainerView.playerViewController.view.frame.size,
url: url
)
.sink { image in
playerContainerView.blurhashOverlayImageView.image = image
}
.store(in: &cell.disposeBag)
}
playerContainerView.isHidden = false
} else {
cell.statusView.playerContainerView.playerViewController.player?.pause()
@ -648,7 +675,13 @@ extension StatusSection {
let name = author.displayName.isEmpty ? author.username : author.displayName
return L10n.Common.Controls.Status.userReblogged(name)
}()
cell.statusView.headerInfoLabel.configure(content: headerText, emojiDict: status.author.emojiDict)
MastodonStatusContent.parseResult(content: headerText, emojiDict: status.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.isAccessibilityElement = true
} else if status.inReplyToID != nil {
cell.statusView.headerContainerView.isHidden = false
@ -661,7 +694,13 @@ extension StatusSection {
let name = author.displayName.isEmpty ? author.username : author.displayName
return L10n.Common.Controls.Status.userRepliedTo(name)
}()
cell.statusView.headerInfoLabel.configure(content: headerText, emojiDict: status.replyTo?.author.emojiDict ?? [:])
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.isAccessibilityElement = true
} else {
cell.statusView.headerContainerView.isHidden = true

View File

@ -61,6 +61,7 @@ extension ActiveLabel {
}
extension ActiveLabel {
/// status content
func configure(content: String, emojiDict: MastodonStatusContent.EmojiDict) {
attributedText = nil
@ -76,6 +77,14 @@ extension ActiveLabel {
}
}
func configure(contentParseResult parseResult: MastodonStatusContent.ParseResult?) {
attributedText = nil
activeEntities.removeAll()
text = parseResult?.trimmed ?? ""
activeEntities = parseResult?.activeEntities ?? []
accessibilityLabel = parseResult?.original ?? nil
}
/// account note
func configure(note: String, emojiDict: MastodonStatusContent.EmojiDict) {
configure(content: note, emojiDict: emojiDict)

View File

@ -22,7 +22,8 @@ extension Date {
if earlierDate.timeIntervalSince(latest) >= -60 {
return L10n.Common.Controls.Timeline.Timestamp.now
} else {
return latest.shortTimeAgo(since: earlierDate)
let interval = latest.shortTimeAgo(since: earlierDate) // 1s
return L10n.Common.Controls.Timeline.Timestamp.timeAgo(interval) // 1s ago
}
}

View File

@ -81,7 +81,7 @@ internal enum Asset {
internal static let searchCard = ColorAsset(name: "Colors/Shadow/SearchCard")
}
internal enum Slider {
internal static let bar = ColorAsset(name: "Colors/Slider/bar")
internal static let track = ColorAsset(name: "Colors/Slider/track")
}
internal enum TextField {
internal static let background = ColorAsset(name: "Colors/TextField/background")

View File

@ -383,6 +383,10 @@ internal enum L10n {
internal enum Timestamp {
/// Now
internal static let now = L10n.tr("Localizable", "Common.Controls.Timeline.Timestamp.Now")
/// %@ ago
internal static func timeAgo(_ p1: Any) -> String {
return L10n.tr("Localizable", "Common.Controls.Timeline.Timestamp.TimeAgo", String(describing: p1))
}
}
}
}

View File

@ -6,6 +6,7 @@
//
import Foundation
import Combine
import Kanna
import ActiveLabel
@ -14,6 +15,18 @@ enum MastodonStatusContent {
typealias EmojiShortcode = String
typealias EmojiDict = [EmojiShortcode: URL]
static let workingQueue = DispatchQueue(label: "org.joinmastodon.app.ActiveLabel.working-queue", qos: .userInteractive, attributes: .concurrent)
static func parseResult(content: String, emojiDict: MastodonStatusContent.EmojiDict) -> AnyPublisher<MastodonStatusContent.ParseResult?, Never> {
return Future { promise in
self.workingQueue.async {
let parseResult = try? MastodonStatusContent.parse(content: content, emojiDict: emojiDict)
promise(.success(parseResult))
}
}
.eraseToAnyPublisher()
}
static func parse(content: String, emojiDict: EmojiDict) throws -> MastodonStatusContent.ParseResult {
let document: String = {
var content = content
@ -113,11 +126,25 @@ extension String {
}
extension MastodonStatusContent {
struct ParseResult {
struct ParseResult: Hashable {
let document: String
let original: String
let trimmed: String
let activeEntities: [ActiveEntity]
static func == (lhs: MastodonStatusContent.ParseResult, rhs: MastodonStatusContent.ParseResult) -> Bool {
return lhs.document == rhs.document
&& lhs.original == rhs.original
&& lhs.trimmed == rhs.trimmed
&& lhs.activeEntities.count == rhs.activeEntities.count // FIXME:
}
func hash(into hasher: inout Hasher) {
hasher.combine(document)
hasher.combine(original)
hasher.combine(trimmed)
hasher.combine(activeEntities.count) // FIXME:
}
}
}

View File

@ -33,17 +33,22 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
guard let self = self else { return }
for objectID in statusObjectIDs {
let status = backgroundManagedObjectContext.object(with: objectID) as! Status
guard let replyToID = status.inReplyToID, status.replyTo == nil else {
// skip
continue
// fetch in-reply info if needs
if let replyToID = status.inReplyToID, status.replyTo == nil {
self.context.statusPrefetchingService.prefetchReplyTo(
domain: domain,
statusObjectID: status.objectID,
statusID: status.id,
replyToStatusID: replyToID,
authorizationBox: activeMastodonAuthenticationBox
)
}
self.context.statusPrefetchingService.prefetchReplyTo(
domain: domain,
statusObjectID: status.objectID,
statusID: status.id,
replyToStatusID: replyToID,
authorizationBox: activeMastodonAuthenticationBox
)
// self.context.statusContentCacheService.prefetch(
// content: (status.reblog ?? status).content,
// emojiDict: (status.reblog ?? status).emojiDict
// )
}
}
}

View File

@ -247,7 +247,7 @@ extension StatusProviderFacade {
}
.map { statusObjectID, favoriteKind -> AnyPublisher<(Status.ID, Mastodon.API.Favorites.FavoriteKind), Error> in
return context.apiService.favorite(
statusObjectID: statusObjectID,
statusObjectID: statusObjectID,
mastodonUserObjectID: mastodonUserObjectID,
favoriteKind: favoriteKind
)

View File

@ -5,9 +5,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "0.600",
"blue" : "0",
"green" : "0",
"red" : "0"
"blue" : "67",
"green" : "60",
"red" : "60"
}
},
"idiom" : "universal"
@ -23,9 +23,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "0.600",
"blue" : "1.000",
"green" : "1.000",
"red" : "1.000"
"blue" : "245",
"green" : "235",
"red" : "235"
}
},
"idiom" : "universal"

View File

@ -1,20 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "147",
"green" : "106",
"red" : "51"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "0.600",
"blue" : "213",
"green" : "213",
"red" : "212"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "0.300",
"blue" : "1.000",
"green" : "1.000",
"red" : "1.000"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -133,6 +133,7 @@ Your account looks like this to them.";
"Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts...";
"Common.Controls.Timeline.Loader.ShowMoreReplies" = "Show more replies";
"Common.Controls.Timeline.Timestamp.Now" = "Now";
"Common.Controls.Timeline.Timestamp.TimeAgo" = "%@ ago";
"Common.Countable.Photo.Multiple" = "photos";
"Common.Countable.Photo.Single" = "photo";
"Scene.Compose.Accessibility.AppendAttachment" = "Append attachment";

View File

@ -133,6 +133,7 @@ Your account looks like this to them.";
"Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts...";
"Common.Controls.Timeline.Loader.ShowMoreReplies" = "Show more replies";
"Common.Controls.Timeline.Timestamp.Now" = "Now";
"Common.Controls.Timeline.Timestamp.TimeAgo" = "%@ ago";
"Common.Countable.Photo.Multiple" = "photos";
"Common.Countable.Photo.Single" = "photo";
"Scene.Compose.Accessibility.AppendAttachment" = "Append attachment";

View File

@ -24,7 +24,7 @@ class HashtagTimelineViewController: UIViewController, NeedsDependency, MediaPre
let composeBarButtonItem: UIBarButtonItem = {
let barButtonItem = UIBarButtonItem()
barButtonItem.tintColor = Asset.Colors.Label.highlight.color
barButtonItem.tintColor = Asset.Colors.brandBlue.color
barButtonItem.image = UIImage(systemName: "square.and.pencil")?.withRenderingMode(.alwaysTemplate)
return barButtonItem
}()

View File

@ -15,6 +15,10 @@ import GameplayKit
import MastodonSDK
import AlamofireImage
#if DEBUG
import GDPerformanceView_Swift
#endif
final class HomeTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
@ -38,14 +42,14 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency, Media
let settingBarButtonItem: UIBarButtonItem = {
let barButtonItem = UIBarButtonItem()
barButtonItem.tintColor = Asset.Colors.Label.highlight.color
barButtonItem.tintColor = Asset.Colors.brandBlue.color
barButtonItem.image = UIImage(systemName: "gear")?.withRenderingMode(.alwaysTemplate)
return barButtonItem
}()
let composeBarButtonItem: UIBarButtonItem = {
let barButtonItem = UIBarButtonItem()
barButtonItem.tintColor = Asset.Colors.Label.highlight.color
barButtonItem.tintColor = Asset.Colors.brandBlue.color
barButtonItem.image = UIImage(systemName: "square.and.pencil")?.withRenderingMode(.alwaysTemplate)
return barButtonItem
}()
@ -87,6 +91,7 @@ extension HomeTimelineViewController {
titleView.delegate = self
viewModel.homeTimelineNavigationBarTitleViewModel.state
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
guard let self = self else { return }
@ -94,10 +99,11 @@ extension HomeTimelineViewController {
}
.store(in: &disposeBag)
#if DEBUG
// long press to trigger debug menu
settingBarButtonItem.menu = debugMenu
PerformanceMonitor.shared().delegate = self
#else
settingBarButtonItem.target = self
settingBarButtonItem.action = #selector(HomeTimelineViewController.settingBarButtonItemPressed(_:))
@ -554,3 +560,11 @@ extension HomeTimelineViewController: StatusTableViewControllerNavigateable {
statusKeyCommandHandler(sender)
}
}
#if DEBUG
extension HomeTimelineViewController: PerformanceMonitorDelegate {
func performanceMonitor(didReport performanceReport: PerformanceReport) {
// print(performanceReport)
}
}
#endif

View File

@ -36,6 +36,7 @@ final class HomeTimelineViewModel: NSObject {
let timelineIsEmpty = CurrentValueSubject<Bool, Never>(false)
let homeTimelineNeedRefresh = PassthroughSubject<Void, Never>()
// output
// top loader
private(set) lazy var loadLatestStateMachine: GKStateMachine = {
@ -130,6 +131,12 @@ final class HomeTimelineViewModel: NSObject {
}
.store(in: &disposeBag)
homeTimelineNavigationBarTitleViewModel.isPublished
.sink { [weak self] isPublished in
guard let self = self else { return }
self.homeTimelineNeedRefresh.send()
}
.store(in: &disposeBag)
}
deinit {

View File

@ -106,9 +106,11 @@ extension MainTabBarController {
selectedIndex = 0
// TODO: custom accent color
// let tabBarAppearance = UITabBarAppearance()
// tabBarAppearance.configureWithDefaultBackground()
// tabBar.standardAppearance = tabBarAppearance
let tabBarAppearance = UITabBarAppearance()
tabBarAppearance.configureWithDefaultBackground()
tabBarAppearance.selectionIndicatorTintColor = Asset.Colors.brandBlue.color
tabBar.standardAppearance = tabBarAppearance
context.apiService.error
.receive(on: DispatchQueue.main)

View File

@ -52,9 +52,10 @@ final class AudioContainerView: UIView {
let slider: UISlider = {
let slider = UISlider()
slider.isContinuous = true
slider.translatesAutoresizingMaskIntoConstraints = false
slider.minimumTrackTintColor = Asset.Colors.Slider.bar.color
slider.maximumTrackTintColor = Asset.Colors.Slider.bar.color
slider.minimumTrackTintColor = Asset.Colors.Slider.track.color
slider.maximumTrackTintColor = Asset.Colors.Slider.track.color
if let image = UIImage.placeholder(size: CGSize(width: 22, height: 22), color: .white).withRoundedCorners(radius: 11) {
slider.setThumbImage(image, for: .normal)
}

View File

@ -104,7 +104,7 @@ extension MosaicImageViewContainer {
imageViews = []
blurhashOverlayImageViews = []
container.spacing = 1
container.spacing = UIView.separatorLineHeight(of: self) * 2 // 2px
}
struct ConfigurableMosaic {
@ -120,12 +120,16 @@ extension MosaicImageViewContainer {
contentView.translatesAutoresizingMaskIntoConstraints = false
container.addArrangedSubview(contentView)
let rect = AVMakeRect(
aspectRatio: aspectRatio,
insideRect: CGRect(origin: .zero, size: maxSize)
)
let imageViewSize: CGSize = {
let rect = AVMakeRect(
aspectRatio: aspectRatio,
insideRect: CGRect(origin: .zero, size: maxSize)
).integral
return rect.size
}()
let imageViewFrame = CGRect(origin: .zero, size: imageViewSize)
let imageView = UIImageView()
let imageView = UIImageView(frame: imageViewFrame)
imageViews.append(imageView)
imageView.layer.masksToBounds = true
imageView.layer.cornerRadius = ContentWarningOverlayView.cornerRadius
@ -138,9 +142,9 @@ extension MosaicImageViewContainer {
imageView.topAnchor.constraint(equalTo: contentView.topAnchor),
imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
imageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
imageView.widthAnchor.constraint(equalToConstant: floor(rect.width)).priority(.required - 1),
imageView.widthAnchor.constraint(equalToConstant: imageViewFrame.width).priority(.required - 1),
])
containerHeightLayoutConstraint.constant = floor(rect.height)
containerHeightLayoutConstraint.constant = imageViewFrame.height
containerHeightLayoutConstraint.isActive = true
let blurhashOverlayImageView = UIImageView()
@ -170,7 +174,7 @@ extension MosaicImageViewContainer {
return ConfigurableMosaic(
imageView: imageView,
blurhashOverlayImageView: blurhashOverlayImageView,
imageViewSize: maxSize
imageViewSize: imageViewSize
)
}
@ -181,6 +185,7 @@ extension MosaicImageViewContainer {
}
let maxHeight = maxSize.height
let spacing: CGFloat = 1
containerHeightLayoutConstraint.constant = maxHeight
containerHeightLayoutConstraint.isActive = true
@ -190,7 +195,7 @@ extension MosaicImageViewContainer {
[contentLeftStackView, contentRightStackView].forEach { stackView in
stackView.axis = .vertical
stackView.distribution = .fillEqually
stackView.spacing = 1
stackView.spacing = spacing
}
container.addArrangedSubview(contentLeftStackView)
container.addArrangedSubview(contentRightStackView)
@ -310,22 +315,23 @@ extension MosaicImageViewContainer {
let imageViewSize: CGSize = {
switch (i, count) {
case (_, 4):
return CGSize(width: maxSize.width * 0.5, height: maxSize.height * 0.5)
return CGSize(width: maxSize.width * 0.5 - spacing, height: maxSize.height * 0.5 - spacing)
case (i, 3):
let width = maxSize.width * 0.5
let width = maxSize.width * 0.5 - spacing
if i == 0 {
return CGSize(width: width, height: maxSize.height)
} else {
return CGSize(width: width, height: maxSize.height * 0.5)
return CGSize(width: width, height: maxSize.height * 0.5 - spacing)
}
case (_, 2):
let width = maxSize.width * 0.5
let width = maxSize.width * 0.5 - spacing
return CGSize(width: width, height: maxSize.height)
default:
assertionFailure()
return maxSize
}
}()
imageView.frame.size = imageViewSize
let mosaic = ConfigurableMosaic(
imageView: imageView,
blurhashOverlayImageView: blurhashOverlayImageView,

View File

@ -9,7 +9,7 @@ import UIKit
extension PlayerContainerView {
final class MediaTypeIndicotorView: UIView {
final class MediaTypeIndicatorView: UIView {
static let indicatorViewSize = CGSize(width: 47, height: 25)
@ -60,7 +60,7 @@ extension PlayerContainerView {
}
extension PlayerContainerView.MediaTypeIndicotorView {
extension PlayerContainerView.MediaTypeIndicatorView {
private func _init() {
backgroundColor = Asset.Colors.Background.mediaTypeIndicotor.color
@ -87,14 +87,10 @@ extension PlayerContainerView.MediaTypeIndicotorView {
switch kind {
case .gif:
label.font = PlayerContainerView.MediaTypeIndicotorView.roundedFont(weight: .heavy, fontSize: fontSize)
label.font = PlayerContainerView.MediaTypeIndicatorView.roundedFont(weight: .heavy, fontSize: fontSize)
label.text = "GIF"
case .video:
let configuration = UIImage.SymbolConfiguration(font: PlayerContainerView.MediaTypeIndicotorView.roundedFont(weight: .regular, fontSize: fontSize))
let image = UIImage(systemName: "video.fill", withConfiguration: configuration)!
let attachment = NSTextAttachment()
attachment.image = image.withTintColor(.white)
label.attributedText = NSAttributedString(attachment: attachment)
label.text = " "
}
}
@ -103,12 +99,12 @@ extension PlayerContainerView.MediaTypeIndicotorView {
#if canImport(SwiftUI) && DEBUG
import SwiftUI
struct PlayerContainerViewMediaTypeIndicotorView_Previews: PreviewProvider {
struct PlayerContainerViewMediaTypeIndicatorView_Previews: PreviewProvider {
static var previews: some View {
Group {
UIViewPreview(width: 47) {
let view = PlayerContainerView.MediaTypeIndicotorView()
let view = PlayerContainerView.MediaTypeIndicatorView()
view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
view.heightAnchor.constraint(equalToConstant: 25),
@ -118,17 +114,6 @@ struct PlayerContainerViewMediaTypeIndicotorView_Previews: PreviewProvider {
return view
}
.previewLayout(.fixed(width: 47, height: 25))
UIViewPreview(width: 47) {
let view = PlayerContainerView.MediaTypeIndicotorView()
view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
view.heightAnchor.constraint(equalToConstant: 25),
view.widthAnchor.constraint(equalToConstant: 47),
])
view.setMediaKind(kind: .video)
return view
}
.previewLayout(.fixed(width: 47, height: 25))
}
}

View File

@ -8,6 +8,7 @@
import os.log
import AVKit
import UIKit
import Combine
protocol PlayerContainerViewDelegate: AnyObject {
func playerContainerView(_ playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
@ -28,11 +29,14 @@ final class PlayerContainerView: UIView {
let playerViewController = AVPlayerViewController()
let mediaTypeIndicotorView = MediaTypeIndicotorView()
let mediaTypeIndicotorViewInContentWarningOverlay = MediaTypeIndicotorView()
let blurhashOverlayImageView = UIImageView()
let mediaTypeIndicatorView = MediaTypeIndicatorView()
weak var delegate: PlayerContainerViewDelegate?
private var isReadyForDisplayObservation: NSKeyValueObservation?
let isReadyForDisplay = CurrentValueSubject<Bool, Never>(false)
override init(frame: CGRect) {
super.init(frame: frame)
_init()
@ -65,25 +69,31 @@ extension PlayerContainerView {
playerViewController.view.layer.cornerRadius = PlayerContainerView.cornerRadius
playerViewController.view.layer.cornerCurve = .continuous
// mediaType
mediaTypeIndicotorView.translatesAutoresizingMaskIntoConstraints = false
playerViewController.contentOverlayView!.addSubview(mediaTypeIndicotorView)
blurhashOverlayImageView.translatesAutoresizingMaskIntoConstraints = false
playerViewController.contentOverlayView!.addSubview(blurhashOverlayImageView)
NSLayoutConstraint.activate([
mediaTypeIndicotorView.bottomAnchor.constraint(equalTo: playerViewController.contentOverlayView!.bottomAnchor),
mediaTypeIndicotorView.rightAnchor.constraint(equalTo: playerViewController.contentOverlayView!.rightAnchor),
mediaTypeIndicotorView.heightAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.height).priority(.required - 1),
mediaTypeIndicotorView.widthAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.width).priority(.required - 1),
blurhashOverlayImageView.topAnchor.constraint(equalTo: playerViewController.contentOverlayView!.topAnchor),
blurhashOverlayImageView.leadingAnchor.constraint(equalTo: playerViewController.contentOverlayView!.leadingAnchor),
blurhashOverlayImageView.trailingAnchor.constraint(equalTo: playerViewController.contentOverlayView!.trailingAnchor),
blurhashOverlayImageView.bottomAnchor.constraint(equalTo: playerViewController.contentOverlayView!.bottomAnchor),
])
mediaTypeIndicotorViewInContentWarningOverlay.translatesAutoresizingMaskIntoConstraints = false
contentWarningOverlayView.addSubview(mediaTypeIndicotorViewInContentWarningOverlay)
// mediaType
mediaTypeIndicatorView.translatesAutoresizingMaskIntoConstraints = false
playerViewController.contentOverlayView!.addSubview(mediaTypeIndicatorView)
NSLayoutConstraint.activate([
mediaTypeIndicotorViewInContentWarningOverlay.bottomAnchor.constraint(equalTo: contentWarningOverlayView.bottomAnchor),
mediaTypeIndicotorViewInContentWarningOverlay.rightAnchor.constraint(equalTo: contentWarningOverlayView.rightAnchor),
mediaTypeIndicotorViewInContentWarningOverlay.heightAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.height).priority(.required - 1),
mediaTypeIndicotorViewInContentWarningOverlay.widthAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.width).priority(.required - 1),
mediaTypeIndicatorView.bottomAnchor.constraint(equalTo: playerViewController.contentOverlayView!.bottomAnchor),
mediaTypeIndicatorView.rightAnchor.constraint(equalTo: playerViewController.contentOverlayView!.rightAnchor),
mediaTypeIndicatorView.heightAnchor.constraint(equalToConstant: MediaTypeIndicatorView.indicatorViewSize.height).priority(.required - 1),
mediaTypeIndicatorView.widthAnchor.constraint(equalToConstant: MediaTypeIndicatorView.indicatorViewSize.width).priority(.required - 1),
])
isReadyForDisplayObservation = playerViewController.observe(\.isReadyForDisplay, options: [.initial, .new]) { [weak self] playerViewController, _ in
guard let self = self else { return }
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: isReadyForDisplay: %s", (#file as NSString).lastPathComponent, #line, #function, playerViewController.isReadyForDisplay.description)
self.isReadyForDisplay.value = playerViewController.isReadyForDisplay
}
contentWarningOverlayView.delegate = self
}
}
@ -104,6 +114,8 @@ extension PlayerContainerView {
playerViewController.view.removeFromSuperview()
playerViewController.removeFromParent()
blurhashOverlayImageView.image = nil
container.subviews.forEach { subview in
subview.removeFromSuperview()
}
@ -123,7 +135,7 @@ extension PlayerContainerView {
let rect = AVMakeRect(
aspectRatio: aspectRatio,
insideRect: CGRect(origin: .zero, size: maxSize)
)
).integral
parent?.addChild(playerViewController)
playerViewController.view.translatesAutoresizingMaskIntoConstraints = false
@ -134,11 +146,13 @@ extension PlayerContainerView {
playerViewController.view.leadingAnchor.constraint(equalTo: touchBlockingView.leadingAnchor),
playerViewController.view.trailingAnchor.constraint(equalTo: touchBlockingView.trailingAnchor),
playerViewController.view.bottomAnchor.constraint(equalTo: touchBlockingView.bottomAnchor),
touchBlockingView.widthAnchor.constraint(equalToConstant: floor(rect.width)).priority(.required - 1),
touchBlockingView.widthAnchor.constraint(equalToConstant: rect.width).priority(.required - 1),
])
containerHeightLayoutConstraint.constant = floor(rect.height)
containerHeightLayoutConstraint.constant = rect.height
containerHeightLayoutConstraint.isActive = true
playerViewController.view.frame.size = rect.size
contentWarningOverlayView.removeFromSuperview()
contentWarningOverlayView.translatesAutoresizingMaskIntoConstraints = false
addSubview(contentWarningOverlayView)
@ -149,19 +163,17 @@ extension PlayerContainerView {
contentWarningOverlayView.bottomAnchor.constraint(equalTo: touchBlockingView.bottomAnchor)
])
bringSubviewToFront(mediaTypeIndicotorView)
bringSubviewToFront(mediaTypeIndicatorView)
return playerViewController
}
func setMediaKind(kind: VideoPlayerViewModel.Kind) {
mediaTypeIndicotorView.setMediaKind(kind: kind)
mediaTypeIndicotorViewInContentWarningOverlay.setMediaKind(kind: kind)
mediaTypeIndicatorView.setMediaKind(kind: kind)
}
func setMediaIndicator(isHidden: Bool) {
mediaTypeIndicotorView.alpha = isHidden ? 0 : 1
mediaTypeIndicotorViewInContentWarningOverlay.alpha = isHidden ? 0 : 1
mediaTypeIndicatorView.alpha = isHidden ? 0 : 1
}
}

View File

@ -253,6 +253,7 @@ extension StatusView {
// header container: [icon | info]
let headerContainerStackView = UIStackView()
headerContainerStackView.axis = .horizontal
headerContainerStackView.spacing = 4
headerContainerStackView.addArrangedSubview(headerIconLabel)
headerContainerStackView.addArrangedSubview(headerInfoLabel)
headerIconLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal)

View File

@ -17,10 +17,10 @@ protocol ActionToolbarContainerDelegate: AnyObject {
final class ActionToolbarContainer: UIView {
let replyButton = HitTestExpandedButton()
let reblogButton = HitTestExpandedButton()
let favoriteButton = HitTestExpandedButton()
let moreButton = HitTestExpandedButton()
let replyButton = HighlightDimmableButton()
let reblogButton = HighlightDimmableButton()
let favoriteButton = HighlightDimmableButton()
let moreButton = HighlightDimmableButton()
var isReblogButtonHighlight: Bool = false {
didSet { isReblogButtonHighlightStateDidChange(to: isReblogButtonHighlight) }
@ -97,6 +97,7 @@ extension ActionToolbarContainer {
button.titleLabel?.font = .monospacedDigitSystemFont(ofSize: 12, weight: .regular)
button.setTitle("", for: .normal)
button.setTitleColor(.secondaryLabel, for: .normal)
button.expandEdgeInsets = UIEdgeInsets(top: -10, left: -10, bottom: -10, right: -10)
button.setInsets(forContentPadding: .zero, imageTitlePadding: style.buttonTitleImagePadding)
}

View File

@ -10,6 +10,7 @@ import Foundation
import UIKit
class AudioContainerViewModel {
static func configure(
cell: StatusCell,
audioAttachment: Attachment,
@ -36,11 +37,12 @@ class AudioContainerViewModel {
}
}
.store(in: &cell.disposeBag)
audioView.slider.maximumValue = Float(duration)
audioView.slider.publisher(for: .valueChanged)
.sink { [weak audioService] slider in
guard let audioService = audioService else { return }
let slider = slider as! UISlider
let time = Double(slider.value) * duration
let time = TimeInterval(slider.value)
audioService.seekToTime(time: time)
}
.store(in: &cell.disposeBag)
@ -58,24 +60,24 @@ class AudioContainerViewModel {
let audioView = cell.statusView.audioView
var lastCurrentTimeSubject: TimeInterval?
audioService.currentTimeSubject
.throttle(for: 0.33, scheduler: DispatchQueue.main, latest: true)
.compactMap { [weak audioService] time -> (TimeInterval, Float)? in
.throttle(for: 0.008, scheduler: DispatchQueue.main, latest: true)
.compactMap { [weak audioService] time -> TimeInterval? in
defer {
lastCurrentTimeSubject = time
}
guard audioAttachment === audioService?.attachment else { return nil }
guard let duration = audioAttachment.meta?.original?.duration else { return nil }
// guard let duration = audioAttachment.meta?.original?.duration else { return nil }
if let lastCurrentTimeSubject = lastCurrentTimeSubject, time != 0.0 {
guard abs(time - lastCurrentTimeSubject) < 0.5 else { return nil } // debounce
}
guard !audioView.slider.isTracking else { return nil }
return (time, Float(time / duration))
return TimeInterval(time)
}
.sink(receiveValue: { time, progress in
.sink(receiveValue: { time in
audioView.timeLabel.text = time.asString(style: .positional)
audioView.slider.setValue(progress, animated: true)
audioView.slider.setValue(Float(time), animated: true)
})
.store(in: &cell.disposeBag)
audioService.playbackState
@ -98,14 +100,14 @@ class AudioContainerViewModel {
switch playbackState {
case .stopped:
audioView.playButton.isSelected = false
audioView.slider.isEnabled = false
audioView.slider.isUserInteractionEnabled = false
audioView.slider.setValue(0, animated: false)
case .paused:
audioView.playButton.isSelected = false
audioView.slider.isEnabled = true
audioView.slider.isUserInteractionEnabled = true
case .playing, .readyToPlay:
audioView.playButton.isSelected = true
audioView.slider.isEnabled = true
audioView.slider.isUserInteractionEnabled = true
default:
assertionFailure()
}

View File

@ -30,6 +30,14 @@ extension APIService {
let targetStatusID = targetStatus.id
_targetStatusID = targetStatusID
let favouritesCount: NSNumber
switch favoriteKind {
case .create:
favouritesCount = NSNumber(value: targetStatus.favouritesCount.intValue + 1)
case .destroy:
favouritesCount = NSNumber(value: max(0, targetStatus.favouritesCount.intValue - 1))
}
targetStatus.update(favouritesCount: favouritesCount)
targetStatus.update(liked: favoriteKind == .create, by: mastodonUser)
}

View File

@ -29,12 +29,17 @@ extension APIService {
let targetStatusID = targetStatus.id
_targetStatusID = targetStatusID
let reblogsCount: NSNumber
switch reblogKind {
case .reblog:
targetStatus.update(reblogged: true, by: mastodonUser)
reblogsCount = NSNumber(value: targetStatus.reblogsCount.intValue + 1)
case .undoReblog:
targetStatus.update(reblogged: false, by: mastodonUser)
reblogsCount = NSNumber(value: max(0, targetStatus.reblogsCount.intValue - 1))
}
targetStatus.update(reblogsCount: reblogsCount)
}
.tryMap { result in

View File

@ -15,22 +15,25 @@ final class BlurhashImageCacheService {
let workingQueue = DispatchQueue(label: "org.joinmastodon.app.BlurhashImageCacheService.working-queue", qos: .userInitiated, attributes: .concurrent)
func image(blurhash: String, size: CGSize, url: URL) -> AnyPublisher<UIImage?, Never> {
let key = Key(blurhash: blurhash, size: size, url: url)
if let image = self.cache.object(forKey: key) {
return Just(image).eraseToAnyPublisher()
}
return Future { promise in
self.workingQueue.async {
let key = Key(blurhash: blurhash, size: size, url: url)
guard let image = self.cache.object(forKey: key) else {
if let image = BlurhashImageCacheService.blurhashImage(blurhash: blurhash, size: size, url: url) {
self.cache.setObject(image, forKey: key)
promise(.success(image))
} else {
promise(.success(nil))
}
guard let image = BlurhashImageCacheService.blurhashImage(blurhash: blurhash, size: size, url: url) else {
promise(.success(nil))
return
}
self.cache.setObject(image, forKey: key)
promise(.success(image))
}
}
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
static func blurhashImage(blurhash: String, size: CGSize, url: URL) -> UIImage? {
@ -55,13 +58,7 @@ final class BlurhashImageCacheService {
}
extension BlurhashImageCacheService {
class Key: Hashable {
static func == (lhs: BlurhashImageCacheService.Key, rhs: BlurhashImageCacheService.Key) -> Bool {
return lhs.blurhash == rhs.blurhash
&& lhs.size == rhs.size
&& lhs.url == rhs.url
}
class Key: NSObject {
let blurhash: String
let size: CGSize
let url: URL
@ -72,11 +69,19 @@ extension BlurhashImageCacheService {
self.url = url
}
func hash(into hasher: inout Hasher) {
hasher.combine(blurhash)
hasher.combine(size.width)
hasher.combine(size.height)
hasher.combine(url)
override func isEqual(_ object: Any?) -> Bool {
guard let object = object as? Key else { return false }
return object.blurhash == blurhash
&& object.size == size
&& object.url == url
}
override var hash: Int {
return blurhash.hashValue ^
size.width.hashValue ^
size.height.hashValue ^
url.hashValue
}
}
}

View File

@ -33,7 +33,7 @@ final class PlaceholderImageCacheService {
}
extension PlaceholderImageCacheService {
class Key: Hashable {
class Key: NSObject {
let color: UIColor
let size: CGSize
let cornerRadius: CGFloat
@ -44,17 +44,18 @@ extension PlaceholderImageCacheService {
self.cornerRadius = cornerRadius
}
static func == (lhs: PlaceholderImageCacheService.Key, rhs: PlaceholderImageCacheService.Key) -> Bool {
return lhs.color == rhs.color
&& lhs.size == rhs.size
&& lhs.cornerRadius == rhs.cornerRadius
override func isEqual(_ object: Any?) -> Bool {
guard let object = object as? Key else { return false }
return object.color == color
&& object.size == size
&& object.cornerRadius == cornerRadius
}
func hash(into hasher: inout Hasher) {
hasher.combine(color)
hasher.combine(size.width)
hasher.combine(size.height)
hasher.combine(cornerRadius)
override var hash: Int {
return color.hashValue ^
size.width.hashValue ^
size.height.hashValue ^
cornerRadius.hashValue
}
}
}

View File

@ -0,0 +1,77 @@
//
// StatusContentCacheService.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-6-17.
//
import UIKit
import Combine
final class StatusContentCacheService {
var disposeBag = Set<AnyCancellable>()
let cache = NSCache<Key, ParseResultWrapper>()
let workingQueue = DispatchQueue(label: "org.joinmastodon.app.BlurhashImageCacheService.working-queue", qos: .userInitiated, attributes: .concurrent)
func parseResult(content: String, emojiDict: MastodonStatusContent.EmojiDict) -> MastodonStatusContent.ParseResult? {
let key = Key(content: content, emojiDict: emojiDict)
return cache.object(forKey: key)?.parseResult
}
func prefetch(content: String, emojiDict: MastodonStatusContent.EmojiDict) {
let key = Key(content: content, emojiDict: emojiDict)
guard cache.object(forKey: key) == nil else { return }
MastodonStatusContent.parseResult(content: content, emojiDict: emojiDict)
.sink { [weak self] parseResult in
guard let self = self else { return }
guard let parseResult = parseResult else { return }
let wrapper = ParseResultWrapper(parseResult: parseResult)
self.cache.setObject(wrapper, forKey: key)
}
.store(in: &disposeBag)
}
}
extension StatusContentCacheService {
class Key: NSObject {
let content: String
let emojiDict: MastodonStatusContent.EmojiDict
init(content: String, emojiDict: MastodonStatusContent.EmojiDict) {
self.content = content
self.emojiDict = emojiDict
}
override func isEqual(_ object: Any?) -> Bool {
guard let object = object as? Key else { return false }
return object.content == content
&& object.emojiDict == emojiDict
}
override var hash: Int {
return content.hashValue ^
emojiDict.hashValue
}
}
class ParseResultWrapper: NSObject {
let parseResult: MastodonStatusContent.ParseResult
init(parseResult: MastodonStatusContent.ParseResult) {
self.parseResult = parseResult
}
override func isEqual(_ object: Any?) -> Bool {
guard let object = object as? ParseResultWrapper else { return false }
return object.parseResult == parseResult
}
override var hash: Int {
return parseResult.hashValue
}
}
}

View File

@ -38,6 +38,7 @@ class AppContext: ObservableObject {
let placeholderImageCacheService = PlaceholderImageCacheService()
let blurhashImageCacheService = BlurhashImageCacheService()
let statusContentCacheService = StatusContentCacheService()
let documentStore: DocumentStore
private var documentStoreSubscription: AnyCancellable!

View File

@ -10,6 +10,10 @@ import UIKit
import UserNotifications
import AppShared
#if DEBUG
import GDPerformanceView_Swift
#endif
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
@ -23,9 +27,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
UserDefaults.standard.setValue(UIApplication.appVersion(), forKey: "Mastodon.appVersion")
UserDefaults.standard.setValue(UIApplication.appBuild(), forKey: "Mastodon.appBundle")
// Setup notification
UNUserNotificationCenter.current().delegate = self
application.registerForRemoteNotifications()
#if DEBUG
PerformanceMonitor.shared().start()
#endif
return true
}

View File

@ -16,6 +16,7 @@ target 'Mastodon' do
# DEBUG
pod 'FLEX', '~> 4.4.0', :configurations => ['Debug']
pod 'GDPerformanceView-Swift', '~> 2.1.1', :configurations => ['Debug']
target 'MastodonTests' do
inherit! :search_paths

View File

@ -1,6 +1,7 @@
PODS:
- DateToolsSwift (5.0.0)
- FLEX (4.4.1)
- GDPerformanceView-Swift (2.1.1)
- Kanna (5.2.4)
- Keys (1.0.1)
- SwiftGen (6.4.0)
@ -9,6 +10,7 @@ PODS:
DEPENDENCIES:
- DateToolsSwift (~> 5.0.0)
- FLEX (~> 4.4.0)
- GDPerformanceView-Swift (~> 2.1.1)
- Kanna (~> 5.2.2)
- Keys (from `Pods/CocoaPodsKeys`)
- SwiftGen (~> 6.4.0)
@ -18,6 +20,7 @@ SPEC REPOS:
trunk:
- DateToolsSwift
- FLEX
- GDPerformanceView-Swift
- Kanna
- SwiftGen
- "UITextField+Shake"
@ -29,11 +32,12 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
DateToolsSwift: 4207ada6ad615d8dc076323d27037c94916dbfa6
FLEX: 7ca2c8cd3a435ff501ff6d2f2141e9bdc934eaab
GDPerformanceView-Swift: 22d964fe40b19e3d914dba2586237d064de8fd77
Kanna: b9d00d7c11428308c7f95e1f1f84b8205f567a8f
Keys: a576f4c9c1c641ca913a959a9c62ed3f215a8de9
SwiftGen: 67860cc7c3cfc2ed25b9b74cfd55495fc89f9108
"UITextField+Shake": 298ac5a0f239d731bdab999b19b628c956ca0ac3
PODFILE CHECKSUM: 0daad1778e56099e7a7e7ebe3d292d20051840fa
PODFILE CHECKSUM: 257c550231fcd1336a29f7835aa331171bb66ebd
COCOAPODS: 1.10.1