Compare commits

..

4 Commits

Author SHA1 Message Date
Shadowfacts 348c306858
Add tapping CW to expand/collapse status
Expand status collapse button tap area to cover stack view spacing
2020-11-03 15:58:08 -05:00
Shadowfacts 0a11d2de47
Fix playing gifs from a background thread 2020-11-03 15:49:30 -05:00
Shadowfacts 4ac76ab672
Add opposite collapse keywords preference 2020-11-03 15:39:02 -05:00
Shadowfacts eb4e6e32f7
Add Grayscale Images preference 2020-11-01 13:59:58 -05:00
28 changed files with 844 additions and 148 deletions

View File

@ -76,6 +76,7 @@ public class Client {
return return
} }
guard let result = try? Client.decoder.decode(Result.self, from: data) else { guard let result = try? Client.decoder.decode(Result.self, from: data) else {
print(request)
completion(.failure(.invalidModel)) completion(.failure(.invalidModel))
return return
} }
@ -90,7 +91,7 @@ public class Client {
func createURLRequest<Result>(request: Request<Result>) -> URLRequest? { func createURLRequest<Result>(request: Request<Result>) -> URLRequest? {
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil } guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil }
components.path = request.path components.path = request.path
components.queryItems = request.queryParameters.queryItems components.queryItems = request.queryParameters.isEmpty ? nil : request.queryParameters.queryItems
guard let url = components.url else { return nil } guard let url = components.url else { return nil }
var urlRequest = URLRequest(url: url, timeoutInterval: timeoutInterval) var urlRequest = URLRequest(url: url, timeoutInterval: timeoutInterval)
urlRequest.httpMethod = request.method.name urlRequest.httpMethod = request.method.name

View File

@ -231,6 +231,8 @@
D6B053AB23BD2F1400A066FA /* AssetCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B053A923BD2F1400A066FA /* AssetCollectionViewCell.swift */; }; D6B053AB23BD2F1400A066FA /* AssetCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B053A923BD2F1400A066FA /* AssetCollectionViewCell.swift */; };
D6B053AC23BD2F1400A066FA /* AssetCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6B053AA23BD2F1400A066FA /* AssetCollectionViewCell.xib */; }; D6B053AC23BD2F1400A066FA /* AssetCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6B053AA23BD2F1400A066FA /* AssetCollectionViewCell.xib */; };
D6B053AE23BD322B00A066FA /* AssetPickerSheetContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B053AD23BD322B00A066FA /* AssetPickerSheetContainerViewController.swift */; }; D6B053AE23BD322B00A066FA /* AssetPickerSheetContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B053AD23BD322B00A066FA /* AssetPickerSheetContainerViewController.swift */; };
D6B17255254F88B800128392 /* OppositeCollapseKeywordsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B17254254F88B800128392 /* OppositeCollapseKeywordsView.swift */; };
D6B30E09254BAF63009CAEE5 /* ImageGrayscalifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B30E08254BAF63009CAEE5 /* ImageGrayscalifier.swift */; };
D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */; }; D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */; };
D6B8DB342182A59300424AF7 /* UIAlertController+Visibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */; }; D6B8DB342182A59300424AF7 /* UIAlertController+Visibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */; };
D6BC874521961F73006163F1 /* Gifu.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D6BC874421961F73006163F1 /* Gifu.framework */; }; D6BC874521961F73006163F1 /* Gifu.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D6BC874421961F73006163F1 /* Gifu.framework */; };
@ -280,8 +282,10 @@
D6E426B9253382B300C02E1C /* SearchResultType.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E426B8253382B300C02E1C /* SearchResultType.swift */; }; D6E426B9253382B300C02E1C /* SearchResultType.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E426B8253382B300C02E1C /* SearchResultType.swift */; };
D6E6F26321603F8B006A8599 /* CharacterCounter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E6F26221603F8B006A8599 /* CharacterCounter.swift */; }; D6E6F26321603F8B006A8599 /* CharacterCounter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E6F26221603F8B006A8599 /* CharacterCounter.swift */; };
D6E6F26521604242006A8599 /* CharacterCounterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E6F26421604242006A8599 /* CharacterCounterTests.swift */; }; D6E6F26521604242006A8599 /* CharacterCounterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E6F26421604242006A8599 /* CharacterCounterTests.swift */; };
D6EAE0DB2550CC8A002DB0AC /* FocusableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */; };
D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */; }; D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */; };
D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */; }; D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */; };
D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EE63FA2551F7F60065485C /* StatusCollapseButton.swift */; };
D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */; }; D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */; };
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */; }; D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */; };
D6F1F84D2193B56E00F5FE67 /* Cache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F1F84C2193B56E00F5FE67 /* Cache.swift */; }; D6F1F84D2193B56E00F5FE67 /* Cache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F1F84C2193B56E00F5FE67 /* Cache.swift */; };
@ -569,6 +573,8 @@
D6B053A923BD2F1400A066FA /* AssetCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetCollectionViewCell.swift; sourceTree = "<group>"; }; D6B053A923BD2F1400A066FA /* AssetCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetCollectionViewCell.swift; sourceTree = "<group>"; };
D6B053AA23BD2F1400A066FA /* AssetCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AssetCollectionViewCell.xib; sourceTree = "<group>"; }; D6B053AA23BD2F1400A066FA /* AssetCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AssetCollectionViewCell.xib; sourceTree = "<group>"; };
D6B053AD23BD322B00A066FA /* AssetPickerSheetContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPickerSheetContainerViewController.swift; sourceTree = "<group>"; }; D6B053AD23BD322B00A066FA /* AssetPickerSheetContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPickerSheetContainerViewController.swift; sourceTree = "<group>"; };
D6B17254254F88B800128392 /* OppositeCollapseKeywordsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OppositeCollapseKeywordsView.swift; sourceTree = "<group>"; };
D6B30E08254BAF63009CAEE5 /* ImageGrayscalifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGrayscalifier.swift; sourceTree = "<group>"; };
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDisplayNameLabel.swift; sourceTree = "<group>"; }; D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDisplayNameLabel.swift; sourceTree = "<group>"; };
D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIAlertController+Visibility.swift"; sourceTree = "<group>"; }; D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIAlertController+Visibility.swift"; sourceTree = "<group>"; };
D6BC874421961F73006163F1 /* Gifu.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Gifu.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D6BC874421961F73006163F1 /* Gifu.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Gifu.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@ -624,8 +630,10 @@
D6E4885C24A2890C0011C13E /* Tusker.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Tusker.entitlements; sourceTree = "<group>"; }; D6E4885C24A2890C0011C13E /* Tusker.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Tusker.entitlements; sourceTree = "<group>"; };
D6E6F26221603F8B006A8599 /* CharacterCounter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CharacterCounter.swift; sourceTree = "<group>"; }; D6E6F26221603F8B006A8599 /* CharacterCounter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CharacterCounter.swift; sourceTree = "<group>"; };
D6E6F26421604242006A8599 /* CharacterCounterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CharacterCounterTests.swift; sourceTree = "<group>"; }; D6E6F26421604242006A8599 /* CharacterCounterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CharacterCounterTests.swift; sourceTree = "<group>"; };
D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusableTextField.swift; sourceTree = "<group>"; };
D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+Scenes.swift"; sourceTree = "<group>"; }; D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+Scenes.swift"; sourceTree = "<group>"; };
D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UISceneSession+MastodonController.swift"; sourceTree = "<group>"; }; D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UISceneSession+MastodonController.swift"; sourceTree = "<group>"; };
D6EE63FA2551F7F60065485C /* StatusCollapseButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCollapseButton.swift; sourceTree = "<group>"; };
D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSplitViewController.swift; sourceTree = "<group>"; }; D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSplitViewController.swift; sourceTree = "<group>"; };
D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSidebarViewController.swift; sourceTree = "<group>"; }; D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSidebarViewController.swift; sourceTree = "<group>"; };
D6F1F84C2193B56E00F5FE67 /* Cache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cache.swift; sourceTree = "<group>"; }; D6F1F84C2193B56E00F5FE67 /* Cache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cache.swift; sourceTree = "<group>"; };
@ -1047,6 +1055,7 @@
04586B4022B2FFB10021BD04 /* PreferencesView.swift */, 04586B4022B2FFB10021BD04 /* PreferencesView.swift */,
04586B4222B301470021BD04 /* AppearancePrefsView.swift */, 04586B4222B301470021BD04 /* AppearancePrefsView.swift */,
0427033722B30F5F000D31B6 /* BehaviorPrefsView.swift */, 0427033722B30F5F000D31B6 /* BehaviorPrefsView.swift */,
D6B17254254F88B800128392 /* OppositeCollapseKeywordsView.swift */,
D680153F2401A6BA00D6103B /* ComposingPrefsView.swift */, D680153F2401A6BA00D6103B /* ComposingPrefsView.swift */,
D68015412401A74600D6103B /* MediaPrefsView.swift */, D68015412401A74600D6103B /* MediaPrefsView.swift */,
D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.swift */, D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.swift */,
@ -1066,6 +1075,7 @@
D663625C2135C74800C9CBA2 /* ConversationMainStatusTableViewCell.xib */, D663625C2135C74800C9CBA2 /* ConversationMainStatusTableViewCell.xib */,
D663625E2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift */, D663625E2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift */,
D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */, D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */,
D6EE63FA2551F7F60065485C /* StatusCollapseButton.swift */,
); );
path = Status; path = Status;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1301,6 +1311,7 @@
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */, D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */,
D6E426802532814100C02E1C /* MaybeLazyStack.swift */, D6E426802532814100C02E1C /* MaybeLazyStack.swift */,
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */, D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */,
D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */,
D67C57A721E2649B00C3118B /* Account Detail */, D67C57A721E2649B00C3118B /* Account Detail */,
D626494023C122C800612E6E /* Asset Picker */, D626494023C122C800612E6E /* Asset Picker */,
D61959D0241E842400A37B8E /* Draft Cell */, D61959D0241E842400A37B8E /* Draft Cell */,
@ -1385,6 +1396,7 @@
D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */, D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */,
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */, D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */,
D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */, D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */,
D6B30E08254BAF63009CAEE5 /* ImageGrayscalifier.swift */,
D67B506B250B28FF00FAECFB /* Vendor */, D67B506B250B28FF00FAECFB /* Vendor */,
D6F1F84E2193B9BE00F5FE67 /* Caching */, D6F1F84E2193B9BE00F5FE67 /* Caching */,
D6757A7A2157E00100721E32 /* XCallbackURL */, D6757A7A2157E00100721E32 /* XCallbackURL */,
@ -1842,6 +1854,7 @@
D60E2F2C24423EAD005F8713 /* LazilyDecoding.swift in Sources */, D60E2F2C24423EAD005F8713 /* LazilyDecoding.swift in Sources */,
D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */, D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */,
D62D2424217ABF3F005076CC /* NSUserActivity+Extensions.swift in Sources */, D62D2424217ABF3F005076CC /* NSUserActivity+Extensions.swift in Sources */,
D6B30E09254BAF63009CAEE5 /* ImageGrayscalifier.swift in Sources */,
D6E426812532814100C02E1C /* MaybeLazyStack.swift in Sources */, D6E426812532814100C02E1C /* MaybeLazyStack.swift in Sources */,
D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */, D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */,
D6A3BC852321F6C100FD64D5 /* AccountListTableViewController.swift in Sources */, D6A3BC852321F6C100FD64D5 /* AccountListTableViewController.swift in Sources */,
@ -1866,6 +1879,7 @@
D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */, D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */,
D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */, D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */,
D627FF7F217E95E000CC0648 /* DraftTableViewCell.swift in Sources */, D627FF7F217E95E000CC0648 /* DraftTableViewCell.swift in Sources */,
D6B17255254F88B800128392 /* OppositeCollapseKeywordsView.swift in Sources */,
D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */, D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */,
D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */, D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */,
D6AEBB4A23216F0400E5038B /* UnfollowAccountActivity.swift in Sources */, D6AEBB4A23216F0400E5038B /* UnfollowAccountActivity.swift in Sources */,
@ -1874,8 +1888,10 @@
D679C09F215850EF00DA27FE /* XCBActions.swift in Sources */, D679C09F215850EF00DA27FE /* XCBActions.swift in Sources */,
D627943223A5466600D38C68 /* SelectableTableViewCell.swift in Sources */, D627943223A5466600D38C68 /* SelectableTableViewCell.swift in Sources */,
D6DD353F22F502EC00A9563A /* Preferences+Notification.swift in Sources */, D6DD353F22F502EC00A9563A /* Preferences+Notification.swift in Sources */,
D6EAE0DB2550CC8A002DB0AC /* FocusableTextField.swift in Sources */,
D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */, D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */,
D6B053A223BD2C0600A066FA /* AssetPickerViewController.swift in Sources */, D6B053A223BD2C0600A066FA /* AssetPickerViewController.swift in Sources */,
D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */,
D627944A23A6AD6100D38C68 /* BookmarksTableViewController.swift in Sources */, D627944A23A6AD6100D38C68 /* BookmarksTableViewController.swift in Sources */,
D64BC19223C271D9000D0238 /* MastodonActivity.swift in Sources */, D64BC19223C271D9000D0238 /* MastodonActivity.swift in Sources */,
D6945C3A23AC75E2005C403C /* FindInstanceViewController.swift in Sources */, D6945C3A23AC75E2005C403C /* FindInstanceViewController.swift in Sources */,

View File

@ -23,8 +23,23 @@ extension StatusState {
let contentWarningCollapsible = !status.spoilerText.isEmpty let contentWarningCollapsible = !status.spoilerText.isEmpty
let collapseDueToContentWarning: Bool?
if contentWarningCollapsible {
let lowercased = status.spoilerText.lowercased()
let opposite = Preferences.shared.oppositeCollapseKeywords.contains { lowercased.contains($0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()) }
if Preferences.shared.expandAllContentWarnings {
collapseDueToContentWarning = opposite
} else {
collapseDueToContentWarning = !opposite
}
} else {
collapseDueToContentWarning = nil
}
self.collapsible = contentWarningCollapsible || longEnoughToCollapse self.collapsible = contentWarningCollapsible || longEnoughToCollapse
self.collapsed = longEnoughToCollapse || (!Preferences.shared.expandAllContentWarnings && contentWarningCollapsible) // use ?? instead of || because the content warnig pref takes priority over length
self.collapsed = collapseDueToContentWarning ?? longEnoughToCollapse
} }
} }

View File

@ -0,0 +1,59 @@
//
// ImageGrayscalifier.swift
// Tusker
//
// Created by Shadowfacts on 10/29/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
struct ImageGrayscalifier {
static let queue = DispatchQueue(label: "ImageGrayscalifier", qos: .default)
private static let context = CIContext()
private static let cache = NSCache<NSURL, UIImage>()
static func convert(url: URL?, data: Data) -> UIImage? {
if let url = url,
let cached = cache.object(forKey: url as NSURL) {
return cached
}
guard let source = CIImage(data: data) else {
return nil
}
return doConvert(source, url: url)
}
static func convert(url: URL?, cgImage: CGImage) -> UIImage? {
if let url = url,
let cached = cache.object(forKey: url as NSURL) {
return cached
}
return doConvert(CIImage(cgImage: cgImage), url: url)
}
private static func doConvert(_ source: CIImage, url: URL?) -> UIImage? {
guard let filter = CIFilter(name: "CIColorMonochrome") else {
return nil
}
filter.setValue(source, forKey: "inputImage")
filter.setValue(CIColor(red: 0.85, green: 0.85, blue: 0.85), forKey: "inputColor")
filter.setValue(1.0, forKey: "inputIntensity")
guard let output = filter.outputImage,
let cgImage = context.createCGImage(output, from: output.extent) else {
return nil
}
let image = UIImage(cgImage: cgImage)
if let url = url {
cache.setObject(image, forKey: url as NSURL)
}
return image
}
}

View File

@ -55,15 +55,13 @@ class Preferences: Codable, ObservableObject {
self.openLinksInApps = try container.decode(Bool.self, forKey: .openLinksInApps) self.openLinksInApps = try container.decode(Bool.self, forKey: .openLinksInApps)
self.useInAppSafari = try container.decode(Bool.self, forKey: .useInAppSafari) self.useInAppSafari = try container.decode(Bool.self, forKey: .useInAppSafari)
self.inAppSafariAutomaticReaderMode = try container.decode(Bool.self, forKey: .inAppSafariAutomaticReaderMode) self.inAppSafariAutomaticReaderMode = try container.decode(Bool.self, forKey: .inAppSafariAutomaticReaderMode)
if container.contains(.expandAllContentWarnings) { self.expandAllContentWarnings = try container.decodeIfPresent(Bool.self, forKey: .expandAllContentWarnings) ?? false
self.expandAllContentWarnings = try container.decode(Bool.self, forKey: .expandAllContentWarnings) self.collapseLongPosts = try container.decodeIfPresent(Bool.self, forKey: .collapseLongPosts) ?? true
} self.oppositeCollapseKeywords = try container.decodeIfPresent([String].self, forKey: .oppositeCollapseKeywords) ?? []
if container.contains(.collapseLongPosts) {
self.collapseLongPosts = try container.decode(Bool.self, forKey: .collapseLongPosts)
}
self.showFavoriteAndReblogCounts = try container.decode(Bool.self, forKey: .showFavoriteAndReblogCounts) self.showFavoriteAndReblogCounts = try container.decode(Bool.self, forKey: .showFavoriteAndReblogCounts)
self.defaultNotificationsMode = try container.decode(NotificationsMode.self, forKey: .defaultNotificationsType) self.defaultNotificationsMode = try container.decode(NotificationsMode.self, forKey: .defaultNotificationsType)
self.grayscaleImages = try container.decodeIfPresent(Bool.self, forKey: .grayscaleImages) ?? false
self.silentActions = try container.decode([String: Permission].self, forKey: .silentActions) self.silentActions = try container.decode([String: Permission].self, forKey: .silentActions)
self.statusContentType = try container.decode(StatusContentType.self, forKey: .statusContentType) self.statusContentType = try container.decode(StatusContentType.self, forKey: .statusContentType)
@ -92,9 +90,11 @@ class Preferences: Codable, ObservableObject {
try container.encode(inAppSafariAutomaticReaderMode, forKey: .inAppSafariAutomaticReaderMode) try container.encode(inAppSafariAutomaticReaderMode, forKey: .inAppSafariAutomaticReaderMode)
try container.encode(expandAllContentWarnings, forKey: .expandAllContentWarnings) try container.encode(expandAllContentWarnings, forKey: .expandAllContentWarnings)
try container.encode(collapseLongPosts, forKey: .collapseLongPosts) try container.encode(collapseLongPosts, forKey: .collapseLongPosts)
try container.encode(oppositeCollapseKeywords, forKey: .oppositeCollapseKeywords)
try container.encode(showFavoriteAndReblogCounts, forKey: .showFavoriteAndReblogCounts) try container.encode(showFavoriteAndReblogCounts, forKey: .showFavoriteAndReblogCounts)
try container.encode(defaultNotificationsMode, forKey: .defaultNotificationsType) try container.encode(defaultNotificationsMode, forKey: .defaultNotificationsType)
try container.encode(grayscaleImages, forKey: .grayscaleImages)
try container.encode(silentActions, forKey: .silentActions) try container.encode(silentActions, forKey: .silentActions)
try container.encode(statusContentType, forKey: .statusContentType) try container.encode(statusContentType, forKey: .statusContentType)
@ -124,16 +124,18 @@ class Preferences: Codable, ObservableObject {
@Published var inAppSafariAutomaticReaderMode = false @Published var inAppSafariAutomaticReaderMode = false
@Published var expandAllContentWarnings = false @Published var expandAllContentWarnings = false
@Published var collapseLongPosts = true @Published var collapseLongPosts = true
@Published var oppositeCollapseKeywords: [String] = []
// MARK: Digital Wellness // MARK: Digital Wellness
@Published var showFavoriteAndReblogCounts = true @Published var showFavoriteAndReblogCounts = true
@Published var defaultNotificationsMode = NotificationsMode.allNotifications @Published var defaultNotificationsMode = NotificationsMode.allNotifications
@Published var grayscaleImages = false
// MARK: Advanced // MARK: Advanced
@Published var silentActions: [String: Permission] = [:] @Published var silentActions: [String: Permission] = [:]
@Published var statusContentType: StatusContentType = .plain @Published var statusContentType: StatusContentType = .plain
enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case theme case theme
case avatarStyle case avatarStyle
case hideCustomEmojiInUsernames case hideCustomEmojiInUsernames
@ -154,9 +156,11 @@ class Preferences: Codable, ObservableObject {
case inAppSafariAutomaticReaderMode case inAppSafariAutomaticReaderMode
case expandAllContentWarnings case expandAllContentWarnings
case collapseLongPosts case collapseLongPosts
case oppositeCollapseKeywords
case showFavoriteAndReblogCounts case showFavoriteAndReblogCounts
case defaultNotificationsType case defaultNotificationsType
case grayscaleImages
case silentActions case silentActions
case statusContentType case statusContentType

View File

@ -11,10 +11,11 @@ import Gifu
import Pachyderm import Pachyderm
import AVFoundation import AVFoundation
protocol LargeImageContentView { protocol LargeImageContentView: UIView {
var animationImage: UIImage? { get } var animationImage: UIImage? { get }
var animationGifData: Data? { get } var animationGifData: Data? { get }
var activityItemsForSharing: [Any] { get } var activityItemsForSharing: [Any] { get }
func grayscaleStateChanged()
} }
class LargeImageImageContentView: UIImageView, GIFAnimatable, LargeImageContentView { class LargeImageImageContentView: UIImageView, GIFAnimatable, LargeImageContentView {
@ -29,6 +30,14 @@ class LargeImageImageContentView: UIImageView, GIFAnimatable, LargeImageContentV
[image!] [image!]
} }
private var sourceData: Data?
convenience init(sourceData data: Data, isGif: Bool) {
self.init(image: UIImage(data: data)!, gifData: isGif ? data : nil)
self.sourceData = data
}
init(image: UIImage, gifData: Data?) { init(image: UIImage, gifData: Data?) {
self.animationGifData = gifData self.animationGifData = gifData
@ -50,6 +59,23 @@ class LargeImageImageContentView: UIImageView, GIFAnimatable, LargeImageContentV
updateImageIfNeeded() updateImageIfNeeded()
} }
func grayscaleStateChanged() {
guard let data = sourceData else {
return
}
let image: UIImage?
if Preferences.shared.grayscaleImages {
image = ImageGrayscalifier.convert(url: nil, data: data)
} else {
image = UIImage(data: data)
}
if let image = image {
self.image = image
}
}
} }
class LargeImageGifvContentView: GifvAttachmentView, LargeImageContentView { class LargeImageGifvContentView: GifvAttachmentView, LargeImageContentView {
@ -85,4 +111,8 @@ class LargeImageGifvContentView: GifvAttachmentView, LargeImageContentView {
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
func grayscaleStateChanged() {
// no-op, GifvAttachmentView observes the grayscale state itself
}
} }

View File

@ -10,8 +10,6 @@ import UIKit
class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeImageAnimatableViewController { class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeImageAnimatableViewController {
typealias ContentView = UIView & LargeImageContentView
weak var animationSourceView: UIImageView? weak var animationSourceView: UIImageView?
var largeImageController: LargeImageViewController? { self } var largeImageController: LargeImageViewController? { self }
var animationImage: UIImage? { contentView.animationImage } var animationImage: UIImage? { contentView.animationImage }
@ -31,7 +29,7 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
@IBOutlet weak var bottomControlsView: UIView! @IBOutlet weak var bottomControlsView: UIView!
@IBOutlet weak var descriptionLabel: UILabel! @IBOutlet weak var descriptionLabel: UILabel!
var contentView: ContentView { var contentView: LargeImageContentView {
didSet { didSet {
oldValue.removeFromSuperview() oldValue.removeFromSuperview()
setupContentView() setupContentView()
@ -50,7 +48,8 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
} }
var shrinkGestureEnabled = true var shrinkGestureEnabled = true
var prevZoomScale: CGFloat? private var prevZoomScale: CGFloat?
private var isGrayscale = false
override var prefersStatusBarHidden: Bool { override var prefersStatusBarHidden: Bool {
return true return true
@ -63,7 +62,7 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
return !controlsVisible return !controlsVisible
} }
init(contentView: ContentView, description: String?, sourceView: UIImageView?) { init(contentView: LargeImageContentView, description: String?, sourceView: UIImageView?) {
self.imageDescription = description self.imageDescription = description
self.animationSourceView = sourceView self.animationSourceView = sourceView
@ -103,6 +102,8 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
let doubleTap = UITapGestureRecognizer(target: self, action: #selector(scrollViewDoubleTapped(_:))) let doubleTap = UITapGestureRecognizer(target: self, action: #selector(scrollViewDoubleTapped(_:)))
doubleTap.numberOfTapsRequired = 2 doubleTap.numberOfTapsRequired = 2
view.addGestureRecognizer(doubleTap) view.addGestureRecognizer(doubleTap)
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
} }
private func setupContentView() { private func setupContentView() {
@ -148,6 +149,13 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
} }
} }
@objc private func preferencesChanged() {
if isGrayscale != Preferences.shared.grayscaleImages {
isGrayscale = Preferences.shared.grayscaleImages
contentView.grayscaleStateChanged()
}
}
func setControlsVisible(_ controlsVisible: Bool, animated: Bool) { func setControlsVisible(_ controlsVisible: Bool, animated: Bool) {
self.controlsVisible = controlsVisible self.controlsVisible = controlsVisible
if animated { if animated {

View File

@ -86,7 +86,7 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie
view.backgroundColor = .black view.backgroundColor = .black
if let data = cache.get(url) { if let data = cache.get(url) {
createLargeImage(data: data) createLargeImage(data: data, url: url)
} else { } else {
createPreview() createPreview()
@ -97,7 +97,7 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie
self.imageRequest = nil self.imageRequest = nil
DispatchQueue.main.async { DispatchQueue.main.async {
self.loadingVC?.removeViewAndController() self.loadingVC?.removeViewAndController()
self.createLargeImage(data: data!) self.createLargeImage(data: data!, url: self.url)
} }
} }
} }
@ -115,12 +115,21 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie
} }
} }
private func createLargeImage(data: Data) { private func createLargeImage(data: Data, url: URL) {
guard !loaded else { return } guard !loaded else { return }
loaded = true loaded = true
guard let image = UIImage(data: data) else { return }
let gifData = url.pathExtension == "gif" ? data : nil let image: UIImage?
createLargeImage(image: image, gifData: gifData) if Preferences.shared.grayscaleImages {
image = ImageGrayscalifier.convert(url: url, data: data)
} else {
image = UIImage(data: data)
}
if let image = image {
let gifData = url.pathExtension == "gif" ? data : nil
createLargeImage(image: image, gifData: gifData)
}
} }
private func createLargeImage(image: UIImage, gifData: Data?) { private func createLargeImage(image: UIImage, gifData: Data?) {
@ -138,8 +147,13 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie
private func createPreview() { private func createPreview() {
guard !self.loaded, guard !self.loaded,
let image = animationSourceView?.image else { return } var image = animationSourceView?.image else { return }
if Preferences.shared.grayscaleImages,
let source = image.cgImage,
let grayscale = ImageGrayscalifier.convert(url: nil, cgImage: source) {
image = grayscale
}
self.createLargeImage(image: image, gifData: nil) self.createLargeImage(image: image, gifData: nil)
} }

View File

@ -36,12 +36,16 @@ struct BehaviorPrefsView: View {
var contentWarningsSection: some View { var contentWarningsSection: some View {
Section(header: Text("Content Warnings")) { Section(header: Text("Content Warnings")) {
Toggle(isOn: $preferences.collapseLongPosts) {
Text("Collapse Long Posts")
}
Toggle(isOn: $preferences.expandAllContentWarnings) { Toggle(isOn: $preferences.expandAllContentWarnings) {
Text("Expand All Content Warnings") Text("Expand All Content Warnings")
} }
Toggle(isOn: $preferences.collapseLongPosts) { NavigationLink(destination: OppositeCollapseKeywordsView()) {
Text("Collapse Long Posts") Text(preferences.expandAllContentWarnings ? "Collapse Posts with Keywords in CWs" : "Expand Posts with Keywords in CWs")
} }
} }
} }

View File

@ -0,0 +1,105 @@
//
// OppositeCollapseKeywordsView.swift
// Tusker
//
// Created by Shadowfacts on 11/1/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import SwiftUI
struct OppositeCollapseKeywordsView: View {
@ObservedObject private var preferences = Preferences.shared
// Can't use the raw [String] for keywords, because we need a fixed ID (within the lifetime of this view) for ForEach
@State private var keywords: [Keyword] = Preferences.shared.oppositeCollapseKeywords.map(Keyword.init) {
didSet {
preferences.oppositeCollapseKeywords = keywords.map(\.value)
}
}
@State private var valueToAdd = ""
@State private var makeAddFieldFirstResponder = false
var body: some View {
ZStack {
// the background from the grouped ListStyle clips to the safe area, so when the keyboard is hiding/showing
// the color behind it can be seen, which looks odd
Color(UIColor.secondarySystemBackground)
.edgesIgnoringSafeArea(.bottom)
List {
Section(footer: Text("A post matches if its content warning contains the text of a keyword, ignoring case.")) {
ForEach(keywords) { (keyword) in
Row(keyword: keyword) {
keywords.removeAll(where: { $0.id == keyword.id })
}
}
.onDelete(perform: self.removeKeywords)
FocusableTextField(placeholder: "Add Keyword", text: $valueToAdd, becomeFirstResponder: $makeAddFieldFirstResponder, onCommit: self.addKeyword)
}
}
.animation(.default)
.listStyle(GroupedListStyle())
}
.onAppear(perform: updateAppearance)
.navigationBarTitle(preferences.expandAllContentWarnings ? "Collapse Post CW Keywords" : "Expand Post CW Keywords")
}
private func updateAppearance() {
UIScrollView.appearance(whenContainedInInstancesOf: [PreferencesNavigationController.self]).keyboardDismissMode = .interactive
}
private func commitExisting(at index: Int) -> () -> Void {
return {
if keywords[index].value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
keywords.remove(at: index)
}
makeAddFieldFirstResponder = true
}
}
private func removeKeywords(_ indices: IndexSet) {
keywords.remove(atOffsets: indices)
}
private func addKeyword() {
guard !valueToAdd.isEmpty else { return }
keywords.append(Keyword(valueToAdd))
valueToAdd = ""
}
}
fileprivate extension OppositeCollapseKeywordsView {
// Class for wrapping keywords that provides a fixed id SwiftUI's ForEach can use
class Keyword: ObservableObject, Identifiable {
let id = UUID()
@Published var value: String
init(_ value: String) {
self.value = value
}
}
}
fileprivate extension OppositeCollapseKeywordsView {
// Use a separate View for the row so it can use @ObservableObject to get a binding for the keyword's value
struct Row: View {
@ObservedObject var keyword: Keyword
let removeKeyword: () -> Void
var body: some View {
FocusableTextField(placeholder: "Keyword", text: $keyword.value) {
if keyword.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
removeKeyword()
}
}
}
}
}
struct OppositeCollapseKeywordsView_Previews: PreviewProvider {
static var previews: some View {
OppositeCollapseKeywordsView()
}
}

View File

@ -9,31 +9,28 @@
import SwiftUI import SwiftUI
struct WellnessPrefsView: View { struct WellnessPrefsView: View {
@ObservedObject var preferences = Preferences.shared @ObservedObject private var preferences = Preferences.shared
var body: some View { var body: some View {
List { List {
showFavAndReblogCountSection showFavAndReblogCount
notificationsModeSection notificationsMode
grayscaleImages
} }
.insetOrGroupedListStyle() .insetOrGroupedListStyle()
.navigationBarTitle(Text("Digital Wellness")) .navigationBarTitle(Text("Digital Wellness"))
} }
var showFavAndReblogCountSection: some View { private var showFavAndReblogCount: some View {
Section(footer: showFavAndReblogCountFooter) { Section(footer: Text("Control whether total favorite and reblog counts are shown for the main post in conversations.")) {
Toggle(isOn: $preferences.showFavoriteAndReblogCounts) { Toggle(isOn: $preferences.showFavoriteAndReblogCounts) {
Text("Show Favorite and Reblog Counts") Text("Show Favorite and Reblog Counts")
} }
} }
} }
var showFavAndReblogCountFooter: some View { private var notificationsMode: some View {
Text("Control whether total favorite and reblog counts are shown for the main post in conversations.") Section(footer: Text("Choose which kinds of notifications will be shown by default in the Notifications tab.")) {
}
var notificationsModeSection: some View {
Section(footer: notificationsModeFooter) {
Picker(selection: $preferences.defaultNotificationsMode, label: Text("Default Notifications Mode")) { Picker(selection: $preferences.defaultNotificationsMode, label: Text("Default Notifications Mode")) {
ForEach(NotificationsMode.allCases, id: \.self) { type in ForEach(NotificationsMode.allCases, id: \.self) { type in
Text(type.displayName).tag(type) Text(type.displayName).tag(type)
@ -42,8 +39,12 @@ struct WellnessPrefsView: View {
} }
} }
var notificationsModeFooter: some View { private var grayscaleImages: some View {
Text("Choose which kinds of notifications will be shown by default in the Notifications tab.") Section(footer: Text("Show attachments, avatars, headers, and custom emoji in black and white.")) {
Toggle(isOn: $preferences.grayscaleImages) {
Text("Grayscale Images")
}
}
} }
} }

View File

@ -40,8 +40,21 @@ class MyProfileViewController: ProfileViewController {
} }
private func setAvatarTabBarImage<Account: AccountProtocol>(account: Account) { private func setAvatarTabBarImage<Account: AccountProtocol>(account: Account) {
_ = ImageCache.avatars.get(account.avatar, completion: { [weak self] (data) in let avatarURL = account.avatar
guard let self = self, let data = data, let image = UIImage(data: data) else { return } _ = ImageCache.avatars.get(avatarURL, completion: { [weak self] (data) in
guard let self = self, let data = data else { return }
let maybeGrayscale: UIImage?
if Preferences.shared.grayscaleImages {
maybeGrayscale = ImageGrayscalifier.convert(url: avatarURL, data: data)
} else {
maybeGrayscale = UIImage(data: data)
}
guard let image = maybeGrayscale else {
return
}
DispatchQueue.main.async { DispatchQueue.main.async {
let size = CGSize(width: 30, height: 30) let size = CGSize(width: 30, height: 30)
let rect = CGRect(origin: .zero, size: size) let rect = CGRect(origin: .zero, size: size)

View File

@ -21,7 +21,8 @@ class AccountTableViewCell: UITableViewCell {
var accountID: String! var accountID: String!
var avatarRequest: ImageCache.Request? private var avatarRequest: ImageCache.Request?
private var isGrayscale = false
override func awakeFromNib() { override func awakeFromNib() {
super.awakeFromNib() super.awakeFromNib()
@ -38,6 +39,10 @@ class AccountTableViewCell: UITableViewCell {
fatalError("Missing cached account \(accountID!)") fatalError("Missing cached account \(accountID!)")
} }
displayNameLabel.updateForAccountDisplayName(account: account) displayNameLabel.updateForAccountDisplayName(account: account)
if isGrayscale != Preferences.shared.grayscaleImages {
updateGrayscaleableUI(account: account)
}
} }
func updateUI(accountID: String) { func updateUI(accountID: String) {
@ -46,21 +51,37 @@ class AccountTableViewCell: UITableViewCell {
fatalError("Missing cached account \(accountID)") fatalError("Missing cached account \(accountID)")
} }
avatarRequest = ImageCache.avatars.get(account.avatar) { [weak self] (data) in usernameLabel.text = "@\(account.acct)"
updateGrayscaleableUI(account: account)
updateUIForPrefrences()
}
private func updateGrayscaleableUI(account: AccountMO) {
isGrayscale = Preferences.shared.grayscaleImages
let accountID = self.accountID
let avatarURL = account.avatar
avatarRequest = ImageCache.avatars.get(avatarURL) { [weak self] (data) in
guard let self = self, let data = data, self.accountID == accountID else { return } guard let self = self, let data = data, self.accountID == accountID else { return }
self.avatarRequest = nil self.avatarRequest = nil
let image: UIImage?
if self.isGrayscale {
image = ImageGrayscalifier.convert(url: avatarURL, data: data)
} else {
image = UIImage(data: data)
}
DispatchQueue.main.async { DispatchQueue.main.async {
self.avatarImageView.image = UIImage(data: data) self.avatarImageView.image = image
} }
} }
usernameLabel.text = "@\(account.acct)"
let doc = try! SwiftSoup.parse(account.note) let doc = try! SwiftSoup.parse(account.note)
noteLabel.text = try! doc.text() noteLabel.text = try! doc.text()
noteLabel.setEmojis(account.emojis, identifier: account.id) noteLabel.setEmojis(account.emojis, identifier: account.id)
updateUIForPrefrences()
} }
override func prepareForReuse() { override func prepareForReuse() {

View File

@ -28,12 +28,22 @@ class AttachmentView: UIImageView, GIFAnimatable {
var expectedSize: CGSize! var expectedSize: CGSize!
private var attachmentRequest: ImageCache.Request? private var attachmentRequest: ImageCache.Request?
private var source: Source?
var gifData: Data? var gifData: Data? {
switch source {
case let .gifData(_, data):
return data
default:
return nil
}
}
private var autoplayGifs: Bool { private var autoplayGifs: Bool {
Preferences.shared.automaticallyPlayGifs && !ProcessInfo.processInfo.isLowPowerModeEnabled Preferences.shared.automaticallyPlayGifs && !ProcessInfo.processInfo.isLowPowerModeEnabled
} }
private var isGrayscale = false
public lazy var animator: Animator? = Animator(withDelegate: self) public lazy var animator: Animator? = Animator(withDelegate: self)
init(attachment: Attachment, index: Int, expectedSize: CGSize) { init(attachment: Attachment, index: Int, expectedSize: CGSize) {
@ -55,19 +65,29 @@ class AttachmentView: UIImageView, GIFAnimatable {
commonInit() commonInit()
} }
func commonInit() { private func commonInit() {
contentMode = .scaleAspectFill contentMode = .scaleAspectFill
layer.masksToBounds = true layer.masksToBounds = true
isUserInteractionEnabled = true isUserInteractionEnabled = true
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(imagePressed))) addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(imagePressed)))
NotificationCenter.default.addObserver(self, selector: #selector(gifPlaybackModeChanged), name: .preferencesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(gifPlaybackModeChanged), name: .NSProcessInfoPowerStateDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(gifPlaybackModeChanged), name: .NSProcessInfoPowerStateDidChange, object: nil)
addInteraction(UIContextMenuInteraction(delegate: self)) addInteraction(UIContextMenuInteraction(delegate: self))
} }
@objc func gifPlaybackModeChanged() { @objc private func preferencesChanged() {
gifPlaybackModeChanged()
if isGrayscale != Preferences.shared.grayscaleImages {
ImageGrayscalifier.queue.async {
self.displayImage()
}
}
}
@objc private func gifPlaybackModeChanged() {
// NSProcessInfoPowerStateDidChange is sometimes fired on a background thread // NSProcessInfoPowerStateDidChange is sometimes fired on a background thread
DispatchQueue.main.async { DispatchQueue.main.async {
if self.attachment.kind == .image, if self.attachment.kind == .image,
@ -106,13 +126,19 @@ class AttachmentView: UIImageView, GIFAnimatable {
} else { } else {
size = self.expectedSize size = self.expectedSize
} }
if let preview = UIImage(blurHash: hash, size: size) {
DispatchQueue.main.async { [weak self] in guard var preview = UIImage(blurHash: hash, size: size) else {
guard let self = self else { return } return
if self.image == nil { }
self.image = preview
} if Preferences.shared.grayscaleImages,
} let grayscale = ImageGrayscalifier.convert(url: nil, cgImage: preview.cgImage!) {
preview = grayscale
}
DispatchQueue.main.async { [weak self] in
guard let self = self, self.image == nil else { return }
self.image = preview
} }
} }
} }
@ -132,35 +158,36 @@ class AttachmentView: UIImageView, GIFAnimatable {
} }
func loadImage() { func loadImage() {
attachmentRequest = ImageCache.attachments.get(attachment.url) { [weak self] (data) in let attachmentURL = attachment.url
attachmentRequest = ImageCache.attachments.get(attachmentURL) { [weak self] (data) in
guard let self = self, let data = data else { return } guard let self = self, let data = data else { return }
self.attachmentRequest = nil self.attachmentRequest = nil
DispatchQueue.main.async { if self.attachment.url.pathExtension == "gif" {
if self.attachment.url.pathExtension == "gif" { self.source = .gifData(attachmentURL, data)
self.gifData = data if self.autoplayGifs {
if self.autoplayGifs { DispatchQueue.main.async {
self.animate(withGIFData: data) self.animate(withGIFData: data)
} else {
self.image = UIImage(data: data)
} }
} else { } else {
self.image = UIImage(data: data) self.displayImage()
} }
} else {
self.source = .imageData(attachmentURL, data)
self.displayImage()
} }
} }
} }
func loadVideo() { func loadVideo() {
let attachmentURL = self.attachment.url let attachmentURL = self.attachment.url
// todo: use a single dispatch queue
DispatchQueue.global(qos: .userInitiated).async { DispatchQueue.global(qos: .userInitiated).async {
let asset = AVURLAsset(url: attachmentURL) let asset = AVURLAsset(url: attachmentURL)
let generator = AVAssetImageGenerator(asset: asset) let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true generator.appliesPreferredTrackTransform = true
guard let image = try? generator.copyCGImage(at: .zero, actualTime: nil) else { return } guard let image = try? generator.copyCGImage(at: .zero, actualTime: nil) else { return }
DispatchQueue.main.async { [weak self] in self.source = .cgImage(attachmentURL, image)
guard let self = self, self.attachment.url == attachmentURL else { return } self.displayImage()
self.image = UIImage(cgImage: image)
}
} }
let playImageView = UIImageView(image: UIImage(systemName: "play.circle.fill")) let playImageView = UIImageView(image: UIImage(systemName: "play.circle.fill"))
@ -202,10 +229,8 @@ class AttachmentView: UIImageView, GIFAnimatable {
let generator = AVAssetImageGenerator(asset: asset) let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true generator.appliesPreferredTrackTransform = true
guard let image = try? generator.copyCGImage(at: .zero, actualTime: nil) else { return } guard let image = try? generator.copyCGImage(at: .zero, actualTime: nil) else { return }
DispatchQueue.main.async { [weak self] in self.source = .cgImage(attachmentURL, image)
guard let self = self, self.attachment.url == attachmentURL else { return } self.displayImage()
self.image = UIImage(cgImage: image)
}
} }
let gifvView = GifvAttachmentView(asset: asset, gravity: .resizeAspectFill) let gifvView = GifvAttachmentView(asset: asset, gravity: .resizeAspectFill)
@ -223,6 +248,35 @@ class AttachmentView: UIImageView, GIFAnimatable {
]) ])
} }
private func displayImage() {
isGrayscale = Preferences.shared.grayscaleImages
let image: UIImage?
switch source {
case nil:
image = nil
case let .imageData(url, data), let .gifData(url, data):
if isGrayscale {
image = ImageGrayscalifier.convert(url: url, data: data)
} else {
image = UIImage(data: data)
}
case let .cgImage(url, cgImage):
if isGrayscale {
image = ImageGrayscalifier.convert(url: url, cgImage: cgImage)
} else {
image = UIImage(cgImage: cgImage)
}
}
DispatchQueue.main.async {
self.image = image
}
}
override func display(_ layer: CALayer) { override func display(_ layer: CALayer) {
super.display(layer) super.display(layer)
@ -242,6 +296,14 @@ class AttachmentView: UIImageView, GIFAnimatable {
} }
fileprivate extension AttachmentView {
enum Source {
case imageData(URL, Data)
case gifData(URL, Data)
case cgImage(URL, CGImage)
}
}
extension AttachmentView: UIContextMenuInteractionDelegate { extension AttachmentView: UIContextMenuInteractionDelegate {
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
return UIContextMenuConfiguration(identifier: nil, previewProvider: { () -> UIViewController? in return UIContextMenuConfiguration(identifier: nil, previewProvider: { () -> UIViewController? in

View File

@ -19,12 +19,16 @@ class GifvAttachmentView: UIView {
layer as! AVPlayerLayer layer as! AVPlayerLayer
} }
let item: AVPlayerItem private var asset: AVAsset
private(set) var item: AVPlayerItem
let player: AVPlayer let player: AVPlayer
private var isGrayscale = false
init(asset: AVAsset, gravity: AVLayerVideoGravity) { init(asset: AVAsset, gravity: AVLayerVideoGravity) {
item = AVPlayerItem(asset: asset) self.asset = asset
item = GifvAttachmentView.createItem(asset: asset)
player = AVPlayer(playerItem: item) player = AVPlayer(playerItem: item)
isGrayscale = Preferences.shared.grayscaleImages
super.init(frame: .zero) super.init(frame: .zero)
@ -33,13 +37,39 @@ class GifvAttachmentView: UIView {
player.isMuted = true player.isMuted = true
NotificationCenter.default.addObserver(self, selector: #selector(restartItem), name: .AVPlayerItemDidPlayToEndTime, object: item) NotificationCenter.default.addObserver(self, selector: #selector(restartItem), name: .AVPlayerItemDidPlayToEndTime, object: item)
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
@objc func restartItem() { private static func createItem(asset: AVAsset) -> AVPlayerItem {
let item = AVPlayerItem(asset: asset)
if Preferences.shared.grayscaleImages {
item.videoComposition = AVVideoComposition(asset: asset, applyingCIFiltersWithHandler: { (request) in
let filter = CIFilter(name: "CIColorMonochrome")!
filter.setValue(request.sourceImage, forKey: "inputImage")
filter.setValue(CIColor(red: 0.85, green: 0.85, blue: 0.85), forKey: "inputColor")
filter.setValue(1.0, forKey: "inputIntensity")
request.finish(with: filter.outputImage!, context: nil)
})
}
return item
}
@objc private func preferencesChanged() {
if isGrayscale != Preferences.shared.grayscaleImages {
isGrayscale = Preferences.shared.grayscaleImages
item = GifvAttachmentView.createItem(asset: asset)
player.replaceCurrentItem(with: item)
player.play()
}
}
@objc private func restartItem() {
item.seek(to: .zero) { (success) in item.seek(to: .zero) { (success) in
guard success else { return } guard success else { return }
self.player.play() self.player.play()

View File

@ -45,10 +45,18 @@ extension BaseEmojiLabel {
group.enter() group.enter()
let request = ImageCache.emojis.get(emoji.url) { (data) in let request = ImageCache.emojis.get(emoji.url) { (data) in
defer { group.leave() } defer { group.leave() }
guard let data = data, let image = UIImage(data: data) else { guard let data = data else {
return return
} }
emojiImages[emoji.shortcode] = image let image: UIImage?
if Preferences.shared.grayscaleImages {
image = ImageGrayscalifier.convert(url: emoji.url, data: data)
} else {
image = UIImage(data: data)
}
if let image = image {
emojiImages[emoji.shortcode] = image
}
} }
if let request = request { if let request = request {
emojiRequests.append(request) emojiRequests.append(request)

View File

@ -59,10 +59,18 @@ class ContentTextView: LinkTextView {
group.enter() group.enter()
_ = ImageCache.emojis.get(emoji.url) { (data) in _ = ImageCache.emojis.get(emoji.url) { (data) in
defer { group.leave() } defer { group.leave() }
guard let data = data, let image = UIImage(data: data) else { guard let data = data else {
return return
} }
emojiImages[emoji.shortcode] = image let image: UIImage?
if Preferences.shared.grayscaleImages {
image = ImageGrayscalifier.convert(url: emoji.url, data: data)
} else {
image = UIImage(data: data)
}
if let image = image {
emojiImages[emoji.shortcode] = image
}
} }
} }

View File

@ -0,0 +1,73 @@
//
// FocusableTextField.swift
// Tusker
//
// Created by Shadowfacts on 11/2/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import SwiftUI
struct FocusableTextField: UIViewRepresentable {
typealias UIViewType = UITextField
let placeholder: String
let text: Binding<String>
let becomeFirstResponder: Binding<Bool>?
let onCommit: (() -> Void)?
init(placeholder: String, text: Binding<String>, becomeFirstResponder: Binding<Bool>? = nil, onCommit: (() -> Void)? = nil) {
self.placeholder = placeholder
self.text = text
self.becomeFirstResponder = becomeFirstResponder
self.onCommit = onCommit
}
func makeUIView(context: Context) -> UITextField {
let field = UITextField()
field.delegate = context.coordinator
field.addTarget(context.coordinator, action: #selector(Coordinator.didChange(_:)), for: .editingChanged)
field.addTarget(context.coordinator, action: #selector(Coordinator.didEnd(_:)), for: .primaryActionTriggered)
return field
}
func updateUIView(_ uiView: UITextField, context: Context) {
uiView.placeholder = placeholder
uiView.text = text.wrappedValue
context.coordinator.text = text
context.coordinator.onCommit = onCommit
if becomeFirstResponder?.wrappedValue == true {
DispatchQueue.main.async {
uiView.becomeFirstResponder()
becomeFirstResponder?.wrappedValue = false
}
}
}
func makeCoordinator() -> Coordinator {
return Coordinator(text: text)
}
class Coordinator: NSObject, UITextFieldDelegate {
var text: Binding<String>
var onCommit: (() -> Void)?
init(text: Binding<String>) {
self.text = text
}
@objc func didChange(_ textField: UITextField) {
text.wrappedValue = textField.text ?? ""
}
@objc func didEnd(_ textField: UITextField) {
onCommit?()
}
func textFieldDidEndEditing(_ textField: UITextField) {
onCommit?()
}
}
}

View File

@ -25,8 +25,9 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
var group: NotificationGroup! var group: NotificationGroup!
var statusID: String! var statusID: String!
var avatarRequests = [String: ImageCache.Request]() private var avatarRequests = [String: ImageCache.Request]()
var updateTimestampWorkItem: DispatchWorkItem? private var updateTimestampWorkItem: DispatchWorkItem?
private var isGrayscale = false
deinit { deinit {
updateTimestampWorkItem?.cancel() updateTimestampWorkItem?.cancel()
@ -44,6 +45,10 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
for case let imageView as UIImageView in actionAvatarStackView.arrangedSubviews { for case let imageView as UIImageView in actionAvatarStackView.arrangedSubviews {
imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: imageView) imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: imageView)
} }
if isGrayscale != Preferences.shared.grayscaleImages {
updateGrayscaleableUI()
}
} }
func updateUI(group: NotificationGroup) { func updateUI(group: NotificationGroup) {
@ -67,6 +72,8 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
fatalError() fatalError()
} }
isGrayscale = Preferences.shared.grayscaleImages
let people = group.notifications.compactMap { mastodonController.persistentContainer.account(for: $0.account.id) } let people = group.notifications.compactMap { mastodonController.persistentContainer.account(for: $0.account.id) }
actionAvatarStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } actionAvatarStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
@ -76,11 +83,22 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
imageView.translatesAutoresizingMaskIntoConstraints = false imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.layer.masksToBounds = true imageView.layer.masksToBounds = true
imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30 imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30
avatarRequests[account.id] = ImageCache.avatars.get(account.avatar) { [weak self] (data) in let avatarURL = account.avatar
avatarRequests[account.id] = ImageCache.avatars.get(avatarURL) { [weak self] (data) in
guard let self = self, let data = data, self.group.id == group.id else { return } guard let self = self, let data = data, self.group.id == group.id else { return }
DispatchQueue.main.async {
self.avatarRequests.removeValue(forKey: account.id) let image: UIImage?
imageView.image = UIImage(data: data) if self.isGrayscale {
image = ImageGrayscalifier.convert(url: avatarURL, data: data)
} else {
image = UIImage(data: data)
}
if let image = image {
DispatchQueue.main.async {
self.avatarRequests.removeValue(forKey: account.id)
imageView.image = image
}
} }
} }
actionAvatarStackView.addArrangedSubview(imageView) actionAvatarStackView.addArrangedSubview(imageView)
@ -104,7 +122,38 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
statusContentLabel.text = try! doc.text() statusContentLabel.text = try! doc.text()
} }
func updateTimestamp() { private func updateGrayscaleableUI() {
let people = group.notifications.compactMap { mastodonController.persistentContainer.account(for: $0.account.id) }
let groupID = group.id
for (index, account) in people.enumerated() {
guard actionAvatarStackView.arrangedSubviews.count > index,
let imageView = actionAvatarStackView.arrangedSubviews[index] as? UIImageView else {
continue
}
let avatarURL = account.avatar
avatarRequests[account.id] = ImageCache.avatars.get(avatarURL) { [weak self] (data) in
guard let self = self, let data = data, self.group.id == groupID else { return }
let image: UIImage?
if self.isGrayscale {
image = ImageGrayscalifier.convert(url: avatarURL, data: data)
} else {
image = UIImage(data: data)
}
if let image = image {
DispatchQueue.main.async {
self.avatarRequests.removeValue(forKey: account.id)
imageView.image = image
}
}
}
}
}
private func updateTimestamp() {
guard let notification = group.notifications.first else { guard let notification = group.notifications.first else {
fatalError("Missing cached notification") fatalError("Missing cached notification")
} }

View File

@ -20,8 +20,9 @@ class FollowNotificationGroupTableViewCell: UITableViewCell {
var group: NotificationGroup! var group: NotificationGroup!
var avatarRequests = [String: ImageCache.Request]() private var avatarRequests = [String: ImageCache.Request]()
var updateTimestampWorkItem: DispatchWorkItem? private var updateTimestampWorkItem: DispatchWorkItem?
private var isGrayscale = false
deinit { deinit {
updateTimestampWorkItem?.cancel() updateTimestampWorkItem?.cancel()
@ -39,6 +40,10 @@ class FollowNotificationGroupTableViewCell: UITableViewCell {
for case let imageView as UIImageView in avatarStackView.arrangedSubviews { for case let imageView as UIImageView in avatarStackView.arrangedSubviews {
imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: imageView) imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: imageView)
} }
if isGrayscale != Preferences.shared.grayscaleImages {
updateGrayscaleableUI()
}
} }
func updateUI(group: NotificationGroup) { func updateUI(group: NotificationGroup) {
@ -51,17 +56,30 @@ class FollowNotificationGroupTableViewCell: UITableViewCell {
}, identifier: group.id) }, identifier: group.id)
updateTimestamp() updateTimestamp()
isGrayscale = Preferences.shared.grayscaleImages
avatarStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } avatarStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
for account in people { for account in people {
let imageView = UIImageView() let imageView = UIImageView()
imageView.translatesAutoresizingMaskIntoConstraints = false imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.layer.masksToBounds = true imageView.layer.masksToBounds = true
imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30 imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30
avatarRequests[account.id] = ImageCache.avatars.get(account.avatar) { [weak self] (data) in let avatarURL = account.avatar
avatarRequests[account.id] = ImageCache.avatars.get(avatarURL) { [weak self] (data) in
guard let self = self, let data = data, self.group.id == group.id else { return } guard let self = self, let data = data, self.group.id == group.id else { return }
DispatchQueue.main.async {
self.avatarRequests.removeValue(forKey: account.id) let image: UIImage?
imageView.image = UIImage(data: data) if Preferences.shared.grayscaleImages {
image = ImageGrayscalifier.convert(url: avatarURL, data: data)
} else {
image = UIImage(data: data)
}
if let image = image {
DispatchQueue.main.async {
self.avatarRequests.removeValue(forKey: account.id)
imageView.image = image
}
} }
} }
avatarStackView.addArrangedSubview(imageView) avatarStackView.addArrangedSubview(imageView)
@ -72,6 +90,39 @@ class FollowNotificationGroupTableViewCell: UITableViewCell {
} }
} }
private func updateGrayscaleableUI() {
isGrayscale = Preferences.shared.grayscaleImages
let people = group.notifications.compactMap { mastodonController.persistentContainer.account(for: $0.account.id) }
let groupID = group.id
for (index, account) in people.enumerated() {
guard avatarStackView.arrangedSubviews.count > index,
let imageView = avatarStackView.arrangedSubviews[index] as? UIImageView else {
continue
}
let avatarURL = account.avatar
avatarRequests[account.id] = ImageCache.avatars.get(avatarURL) { [weak self] (data) in
guard let self = self, let data = data, self.group.id == groupID else { return }
let image: UIImage?
if self.isGrayscale {
image = ImageGrayscalifier.convert(url: avatarURL, data: data)
} else {
image = UIImage(data: data)
}
if let image = image {
DispatchQueue.main.async {
self.avatarRequests.removeValue(forKey: account.id)
imageView.image = image
}
}
}
}
}
func updateActionLabel(names: [NSAttributedString]) -> NSAttributedString { func updateActionLabel(names: [NSAttributedString]) -> NSAttributedString {
// todo: figure out how to localize this // todo: figure out how to localize this
let str = NSMutableAttributedString(string: "Followed by ") let str = NSMutableAttributedString(string: "Followed by ")

View File

@ -25,8 +25,9 @@ class FollowRequestNotificationTableViewCell: UITableViewCell {
var notification: Pachyderm.Notification? var notification: Pachyderm.Notification?
var account: Account! var account: Account!
var avatarRequest: ImageCache.Request? private var avatarRequest: ImageCache.Request?
var updateTimestampWorkItem: DispatchWorkItem? private var updateTimestampWorkItem: DispatchWorkItem?
private var isGrayscale = false
deinit { deinit {
updateTimestampWorkItem?.cancel() updateTimestampWorkItem?.cancel()
@ -43,6 +44,11 @@ class FollowRequestNotificationTableViewCell: UITableViewCell {
@objc func updateUIForPreferences() { @objc func updateUIForPreferences() {
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30 avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30
if isGrayscale != Preferences.shared.grayscaleImages,
let account = self.account {
updateUI(account: account)
}
} }
func updateUI(notification: Pachyderm.Notification) { func updateUI(notification: Pachyderm.Notification) {
@ -61,16 +67,27 @@ class FollowRequestNotificationTableViewCell: UITableViewCell {
actionLabel.text = "Request to follow from \(account.displayName)" actionLabel.text = "Request to follow from \(account.displayName)"
actionLabel.setEmojis(account.emojis, identifier: account.id) actionLabel.setEmojis(account.emojis, identifier: account.id)
} }
avatarRequest = ImageCache.avatars.get(account.avatar) { [weak self] (data) in let avatarURL = account.avatar
guard let self = self, self.account == account, let data = data, let image = UIImage(data: data) else { return } avatarRequest = ImageCache.avatars.get(avatarURL) { [weak self] (data) in
guard let self = self, self.account == account, let data = data else { return }
self.avatarRequest = nil self.avatarRequest = nil
DispatchQueue.main.async {
self.avatarImageView.image = image let image: UIImage?
if self.isGrayscale {
image = ImageGrayscalifier.convert(url: avatarURL, data: data)
} else {
image = UIImage(data: data)
}
if let image = image {
DispatchQueue.main.async {
self.avatarImageView.image = image
}
} }
} }
} }
func updateTimestamp() { private func updateTimestamp() {
guard let notification = notification else { return } guard let notification = notification else { return }
timestampLabel.text = notification.createdAt.timeAgoString() timestampLabel.text = notification.createdAt.timeAgoString()

View File

@ -48,6 +48,8 @@ class ProfileHeaderView: UIView {
private var avatarRequest: ImageCache.Request? private var avatarRequest: ImageCache.Request?
private var headerRequest: ImageCache.Request? private var headerRequest: ImageCache.Request?
private var isGrayscale = false
private var cancellables = [AnyCancellable]() private var cancellables = [AnyCancellable]()
deinit { deinit {
@ -106,22 +108,7 @@ class ProfileHeaderView: UIView {
usernameLabel.text = "@\(account.acct)" usernameLabel.text = "@\(account.acct)"
avatarRequest = ImageCache.avatars.get(account.avatar) { [weak self] (data) in updateImages(account: account)
guard let self = self, let data = data, self.accountID == accountID else { return }
self.avatarRequest = nil
DispatchQueue.main.async {
self.avatarImageView.image = UIImage(data: data)
}
}
if let header = account.header {
headerRequest = ImageCache.headers.get(header) { [weak self] (data) in
guard let self = self, let data = data, self.accountID == accountID else { return }
self.headerRequest = nil
DispatchQueue.main.async {
self.headerImageView.image = UIImage(data: data)
}
}
}
if #available(iOS 14.0, *) { if #available(iOS 14.0, *) {
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: actionsForProfile(accountID: accountID, sourceView: moreButton)) moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: actionsForProfile(accountID: accountID, sourceView: moreButton))
@ -193,6 +180,49 @@ class ProfileHeaderView: UIView {
avatarContainerView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarContainerView) avatarContainerView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarContainerView)
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView) avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
displayNameLabel.updateForAccountDisplayName(account: account) displayNameLabel.updateForAccountDisplayName(account: account)
if isGrayscale != Preferences.shared.grayscaleImages {
updateImages(account: account)
}
}
private func updateImages(account: AccountMO) {
isGrayscale = Preferences.shared.grayscaleImages
let accountID = account.id
let avatarURL = account.avatar
avatarRequest = ImageCache.avatars.get(avatarURL) { [weak self] (data) in
guard let self = self, let data = data, self.accountID == accountID else { return }
self.avatarRequest = nil
let image: UIImage?
if self.isGrayscale {
image = ImageGrayscalifier.convert(url: avatarURL, data: data)
} else {
image = UIImage(data: data)
}
DispatchQueue.main.async {
self.avatarImageView.image = image
}
}
if let header = account.header {
headerRequest = ImageCache.headers.get(header) { [weak self] (data) in
guard let self = self, let data = data, self.accountID == accountID else { return }
self.headerRequest = nil
let image: UIImage?
if self.isGrayscale {
image = ImageGrayscalifier.convert(url: header, data: data)
} else {
image = UIImage(data: data)
}
DispatchQueue.main.async {
self.headerImageView.image = image
}
}
}
} }
// MARK: Interaction // MARK: Interaction

View File

@ -74,6 +74,8 @@ class BaseStatusTableViewCell: UITableViewCell {
private var currentPictureInPictureVideoStatusID: String? private var currentPictureInPictureVideoStatusID: String?
private var isGrayscale = false
override func awakeFromNib() { override func awakeFromNib() {
super.awakeFromNib() super.awakeFromNib()
@ -96,6 +98,8 @@ class BaseStatusTableViewCell: UITableViewCell {
} }
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
contentWarningLabel.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(collapseButtonPressed)))
} }
open func createObserversIfNecessary() { open func createObserversIfNecessary() {
@ -142,6 +146,7 @@ class BaseStatusTableViewCell: UITableViewCell {
let account = status.account let account = status.account
self.accountID = account.id self.accountID = account.id
updateUI(account: account) updateUI(account: account)
updateGrayscaleableUI(account: account, status: status)
updateUIForPreferences(account: account, status: status) updateUIForPreferences(account: account, status: status)
cardView.card = status.card cardView.card = status.card
@ -154,8 +159,6 @@ class BaseStatusTableViewCell: UITableViewCell {
updateStatusState(status: status) updateStatusState(status: status)
contentTextView.setTextFrom(status: status)
contentWarningLabel.text = status.spoilerText contentWarningLabel.text = status.spoilerText
contentWarningLabel.isHidden = status.spoilerText.isEmpty contentWarningLabel.isHidden = status.spoilerText.isEmpty
if !contentWarningLabel.isHidden { if !contentWarningLabel.isHidden {
@ -215,12 +218,6 @@ class BaseStatusTableViewCell: UITableViewCell {
func updateUI(account: AccountMO) { func updateUI(account: AccountMO) {
usernameLabel.text = "@\(account.acct)" usernameLabel.text = "@\(account.acct)"
avatarImageView.image = nil avatarImageView.image = nil
avatarRequest = ImageCache.avatars.get(account.avatar) { [weak self] (data) in
DispatchQueue.main.async {
guard let self = self, let data = data, self.accountID == account.id else { return }
self.avatarImageView.image = UIImage(data: data)
}
}
} }
@objc private func preferencesChanged() { @objc private func preferencesChanged() {
@ -232,10 +229,13 @@ class BaseStatusTableViewCell: UITableViewCell {
func updateUIForPreferences(account: AccountMO, status: StatusMO) { func updateUIForPreferences(account: AccountMO, status: StatusMO) {
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView) avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
displayNameLabel.updateForAccountDisplayName(account: account)
attachmentsView.contentHidden = Preferences.shared.blurAllMedia || (mastodonController.persistentContainer.status(for: statusID)?.sensitive ?? false) attachmentsView.contentHidden = Preferences.shared.blurAllMedia || (mastodonController.persistentContainer.status(for: statusID)?.sensitive ?? false)
updateStatusIconsForPreferences(status) updateStatusIconsForPreferences(status)
if isGrayscale != Preferences.shared.grayscaleImages {
updateGrayscaleableUI(account: account, status: status)
}
} }
func updateStatusIconsForPreferences(_ status: StatusMO) { func updateStatusIconsForPreferences(_ status: StatusMO) {
@ -253,6 +253,31 @@ class BaseStatusTableViewCell: UITableViewCell {
reblogButton.setImage(reblogButtonImage, for: .normal) reblogButton.setImage(reblogButtonImage, for: .normal)
} }
func updateGrayscaleableUI(account: AccountMO, status: StatusMO) {
isGrayscale = Preferences.shared.grayscaleImages
let avatarURL = account.avatar
let accountID = account.id
avatarRequest = ImageCache.avatars.get(avatarURL) { [weak self] (data) in
guard let self = self, let data = data, self.accountID == accountID else { return }
let image: UIImage?
if self.isGrayscale {
image = ImageGrayscalifier.convert(url: avatarURL, data: data)
} else {
image = UIImage(data: data)
}
DispatchQueue.main.async {
self.avatarImageView.image = image
}
}
contentTextView.setTextFrom(status: status)
displayNameLabel.updateForAccountDisplayName(account: account)
}
override func prepareForReuse() { override func prepareForReuse() {
super.prepareForReuse() super.prepareForReuse()

View File

@ -66,8 +66,8 @@
<constraint firstItem="3Qu-IO-5wt" firstAttribute="top" secondItem="Cnd-Fj-B7l" secondAttribute="top" id="pPU-WS-Y6B"/> <constraint firstItem="3Qu-IO-5wt" firstAttribute="top" secondItem="Cnd-Fj-B7l" secondAttribute="top" id="pPU-WS-Y6B"/>
</constraints> </constraints>
</view> </view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="252" verticalCompressionResistancePriority="751" text="Content Warning" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="cwQ-mR-L1b" customClass="EmojiLabel" customModule="Tusker" customModuleProvider="target"> <label opaque="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="252" verticalCompressionResistancePriority="751" text="Content Warning" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="cwQ-mR-L1b" customClass="EmojiLabel" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="58" width="137.5" height="20.5"/> <rect key="frame" x="0.0" y="58" width="343" height="20.5"/>
<accessibility key="accessibilityConfiguration"> <accessibility key="accessibilityConfiguration">
<accessibilityTraits key="traits" staticText="YES" notEnabled="YES"/> <accessibilityTraits key="traits" staticText="YES" notEnabled="YES"/>
</accessibility> </accessibility>
@ -75,7 +75,7 @@
<color key="textColor" systemColor="secondaryLabelColor"/> <color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/> <nil key="highlightedColor"/>
</label> </label>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="8r8-O8-Agh"> <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="8r8-O8-Agh" customClass="StatusCollapseButton" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="86.5" width="343" height="30"/> <rect key="frame" x="0.0" y="86.5" width="343" height="30"/>
<color key="backgroundColor" systemColor="systemBlueColor"/> <color key="backgroundColor" systemColor="systemBlueColor"/>
<constraints> <constraints>
@ -216,6 +216,7 @@
<constraint firstItem="Cnd-Fj-B7l" firstAttribute="width" secondItem="GuG-Qd-B8I" secondAttribute="width" id="2hS-RG-81T"/> <constraint firstItem="Cnd-Fj-B7l" firstAttribute="width" secondItem="GuG-Qd-B8I" secondAttribute="width" id="2hS-RG-81T"/>
<constraint firstItem="z0g-HN-gS0" firstAttribute="width" secondItem="GuG-Qd-B8I" secondAttribute="width" id="4TF-2Z-mdf"/> <constraint firstItem="z0g-HN-gS0" firstAttribute="width" secondItem="GuG-Qd-B8I" secondAttribute="width" id="4TF-2Z-mdf"/>
<constraint firstItem="IF9-9U-Gk0" firstAttribute="width" secondItem="GuG-Qd-B8I" secondAttribute="width" id="8A8-wi-7sg"/> <constraint firstItem="IF9-9U-Gk0" firstAttribute="width" secondItem="GuG-Qd-B8I" secondAttribute="width" id="8A8-wi-7sg"/>
<constraint firstItem="cwQ-mR-L1b" firstAttribute="width" secondItem="GuG-Qd-B8I" secondAttribute="width" id="O32-3Q-mUs"/>
<constraint firstItem="8r8-O8-Agh" firstAttribute="width" secondItem="GuG-Qd-B8I" secondAttribute="width" id="bZv-bR-jJ3"/> <constraint firstItem="8r8-O8-Agh" firstAttribute="width" secondItem="GuG-Qd-B8I" secondAttribute="width" id="bZv-bR-jJ3"/>
<constraint firstItem="ejU-sO-Og5" firstAttribute="width" secondItem="GuG-Qd-B8I" secondAttribute="width" id="biK-oQ-SLy"/> <constraint firstItem="ejU-sO-Og5" firstAttribute="width" secondItem="GuG-Qd-B8I" secondAttribute="width" id="biK-oQ-SLy"/>
<constraint firstItem="3Bg-XP-d13" firstAttribute="width" secondItem="GuG-Qd-B8I" secondAttribute="width" id="iIq-gh-90O"/> <constraint firstItem="3Bg-XP-d13" firstAttribute="width" secondItem="GuG-Qd-B8I" secondAttribute="width" id="iIq-gh-90O"/>

View File

@ -26,6 +26,7 @@ class StatusCardView: UIView {
private let inactiveBackgroundColor = UIColor.secondarySystemBackground private let inactiveBackgroundColor = UIColor.secondarySystemBackground
private var imageRequest: ImageCache.Request? private var imageRequest: ImageCache.Request?
private var isGrayscale = false
private var titleLabel: UILabel! private var titleLabel: UILabel!
private var descriptionLabel: UILabel! private var descriptionLabel: UILabel!
@ -108,26 +109,15 @@ class StatusCardView: UIView {
placeholderImageView.centerXAnchor.constraint(equalTo: imageView.centerXAnchor), placeholderImageView.centerXAnchor.constraint(equalTo: imageView.centerXAnchor),
placeholderImageView.centerYAnchor.constraint(equalTo: imageView.centerYAnchor), placeholderImageView.centerYAnchor.constraint(equalTo: imageView.centerYAnchor),
]) ])
NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil)
} }
private func updateUI(card: Card) { private func updateUI(card: Card) {
self.imageView.image = nil self.imageView.image = nil
if let image = card.image { updateGrayscaleableUI(card: card)
placeholderImageView.isHidden = true updateUIForPreferences()
imageRequest = ImageCache.attachments.get(image, completion: { (data) in
guard let data = data, let image = UIImage(data: data) else { return }
DispatchQueue.main.async {
self.imageView.image = image
}
})
if imageRequest != nil {
loadBlurHash()
}
} else {
placeholderImageView.isHidden = false
}
let title = card.title.trimmingCharacters(in: .whitespacesAndNewlines) let title = card.title.trimmingCharacters(in: .whitespacesAndNewlines)
titleLabel.text = title titleLabel.text = title
@ -138,6 +128,41 @@ class StatusCardView: UIView {
descriptionLabel.isHidden = description.isEmpty descriptionLabel.isHidden = description.isEmpty
} }
@objc private func updateUIForPreferences() {
if isGrayscale != Preferences.shared.grayscaleImages,
let card = card {
updateGrayscaleableUI(card: card)
}
}
private func updateGrayscaleableUI(card: Card) {
isGrayscale = Preferences.shared.grayscaleImages
if let imageURL = card.image {
placeholderImageView.isHidden = true
imageRequest = ImageCache.attachments.get(imageURL, completion: { (data) in
guard let data = data else { return }
let image: UIImage?
if self.isGrayscale {
image = ImageGrayscalifier.convert(url: imageURL, data: data)
} else {
image = UIImage(data: data)
}
if let image = image {
DispatchQueue.main.async {
self.imageView.image = image
}
}
})
if imageRequest != nil {
loadBlurHash()
}
} else {
placeholderImageView.isHidden = false
}
}
private func loadBlurHash() { private func loadBlurHash() {
guard let card = card, let hash = card.blurhash else { return } guard let card = card, let hash = card.blurhash else { return }

View File

@ -0,0 +1,25 @@
//
// StatusCollapseButton.swift
// Tusker
//
// Created by Shadowfacts on 11/3/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
class StatusCollapseButton: UIButton {
private var interactionBounds: CGRect!
override func layoutSubviews() {
super.layoutSubviews()
interactionBounds = bounds.inset(by: UIEdgeInsets(top: -8, left: 0, bottom: 0, right: 0))
}
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
return interactionBounds.contains(point)
}
}

View File

@ -94,8 +94,8 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
pinImageView.isHidden = !pinned pinImageView.isHidden = !pinned
} }
override func updateUIForPreferences(account: AccountMO, status: StatusMO) { override func updateGrayscaleableUI(account: AccountMO, status: StatusMO) {
super.updateUIForPreferences(account: account, status: status) super.updateGrayscaleableUI(account: account, status: status)
if let rebloggerID = rebloggerID, if let rebloggerID = rebloggerID,
let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) { let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {

View File

@ -84,16 +84,17 @@
<constraint firstAttribute="height" secondItem="gll-xe-FSr" secondAttribute="height" id="B7p-Pc-fZD"/> <constraint firstAttribute="height" secondItem="gll-xe-FSr" secondAttribute="height" id="B7p-Pc-fZD"/>
</constraints> </constraints>
</stackView> </stackView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="252" verticalCompressionResistancePriority="755" text="Content Warning" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="inI-Og-YiU" customClass="EmojiLabel" customModule="Tusker" customModuleProvider="target"> <label opaque="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="252" verticalCompressionResistancePriority="755" text="Content Warning" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="inI-Og-YiU" customClass="EmojiLabel" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="24.5" width="277" height="20.5"/> <rect key="frame" x="0.0" y="24.5" width="277" height="20.5"/>
<accessibility key="accessibilityConfiguration"> <accessibility key="accessibilityConfiguration">
<accessibilityTraits key="traits" staticText="YES" notEnabled="YES"/> <accessibilityTraits key="traits" staticText="YES" notEnabled="YES"/>
</accessibility> </accessibility>
<gestureRecognizers/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="17"/> <fontDescription key="fontDescription" type="boldSystem" pointSize="17"/>
<color key="textColor" systemColor="secondaryLabelColor"/> <color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/> <nil key="highlightedColor"/>
</label> </label>
<button opaque="NO" contentMode="scaleToFill" verticalHuggingPriority="252" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="O0E-Vf-XYR"> <button opaque="NO" contentMode="scaleToFill" verticalHuggingPriority="252" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="O0E-Vf-XYR" customClass="StatusCollapseButton" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="49" width="277" height="30"/> <rect key="frame" x="0.0" y="49" width="277" height="30"/>
<color key="backgroundColor" systemColor="systemBlueColor"/> <color key="backgroundColor" systemColor="systemBlueColor"/>
<constraints> <constraints>