Compare commits

..

No commits in common. "fc7e7f502be7c61dc02a584597dee86ea62aa026" and "6261318df12980955cf5c7b2f2dd338ab8d5c3b9" have entirely different histories.

48 changed files with 449 additions and 797 deletions

View File

@ -1,19 +1,5 @@
# Changelog
## 2023.5 (90)
Features/Improvements:
- Improve performance when scrolling through timeline
- Improve error messages when editing filters
- Enable editing posts on Pleroma 2.5+
Bugfixes:
- Fix share sheet extension not working with Apple News
- Fix crash when sharing certain photos with share extension
- Fix reblog button being enabled on Direct posts
- Fix expanded statuses collapsing when opening Conversation
- Fix main post in Conversation flickering when context loaded
- Fix link card images not loading on Mastodon
## 2023.5 (89)
This build is a hotfix for an issue loading notifications in certain circumstances. The changelong for the previous build (adding post editing) is included below.

View File

@ -95,21 +95,19 @@ class AttachmentThumbnailController: ViewController {
self.gifController = GIFController(gifData: data)
} else if type.conforms(to: .image),
let image = UIImage(data: data) {
// using prepareThumbnail on images from PHPicker results in extremely high memory usage,
// crashing share extension. see FB12186346
// if fullSize {
if fullSize {
image.prepareForDisplay { prepared in
DispatchQueue.main.async {
self.image = image
self.image = prepared
}
}
} else {
image.prepareThumbnail(of: CGSize(width: 80, height: 80)) { prepared in
DispatchQueue.main.async {
self.image = prepared
}
}
}
// } else {
// image.prepareThumbnail(of: CGSize(width: 80, height: 80)) { prepared in
// DispatchQueue.main.async {
// self.image = prepared
// }
// }
// }
}
}
}

View File

@ -88,7 +88,7 @@ public class InstanceFeatures: ObservableObject {
}
public var needsWideColorGamutHack: Bool {
if case .mastodon(_, let version) = instanceType {
if case .mastodon(_, .some(let version)) = instanceType {
return version < Version(4, 0, 0)
} else {
return true
@ -116,16 +116,8 @@ public class InstanceFeatures: ObservableObject {
}
public var editStatuses: Bool {
switch instanceType {
case .mastodon(_, let v) where v >= Version(3, 5, 0):
return true
case .pleroma(.vanilla(let v)) where v >= Version(2, 5, 0):
return true
case .pleroma(.akkoma(nil)):
return true
default:
return false
}
// todo: does this require a particular akkoma version?
hasMastodonVersion(3, 5, 0) || instanceType.isPleroma(.akkoma(nil))
}
public var needsEditAttachmentsInSeparateRequest: Bool {
@ -196,7 +188,7 @@ public class InstanceFeatures: ObservableObject {
}
public func hasMastodonVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
if case .mastodon(_, let version) = instanceType {
if case .mastodon(_, .some(let version)) = instanceType {
return version >= Version(major, minor, patch)
} else {
return false
@ -205,7 +197,7 @@ public class InstanceFeatures: ObservableObject {
func hasPleromaVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
switch instanceType {
case .pleroma(.vanilla(let version)), .pleroma(.akkoma(let version)):
case .pleroma(.vanilla(.some(let version))), .pleroma(.akkoma(.some(let version))):
return version >= Version(major, minor, patch)
default:
return false
@ -291,3 +283,61 @@ extension InstanceFeatures {
}
}
}
extension InstanceFeatures {
@_spi(InstanceType) public struct Version: Equatable, Comparable, CustomStringConvertible {
private static let regex = try! NSRegularExpression(pattern: "^(\\d+)\\.(\\d+)\\.(\\d+).*$")
let major: Int
let minor: Int
let patch: Int
init(_ major: Int, _ minor: Int, _ patch: Int) {
self.major = major
self.minor = minor
self.patch = patch
}
init?(string: String) {
guard let match = Version.regex.firstMatch(in: string, range: NSRange(location: 0, length: string.utf16.count)),
match.numberOfRanges == 4 else {
return nil
}
let majorStr = (string as NSString).substring(with: match.range(at: 1))
let minorStr = (string as NSString).substring(with: match.range(at: 2))
let patchStr = (string as NSString).substring(with: match.range(at: 3))
guard let major = Int(majorStr),
let minor = Int(minorStr),
let patch = Int(patchStr) else {
return nil
}
self.major = major
self.minor = minor
self.patch = patch
}
public var description: String {
"\(major).\(minor).\(patch)"
}
public static func ==(lhs: Version, rhs: Version) -> Bool {
return lhs.major == rhs.major && lhs.minor == rhs.minor && lhs.patch == rhs.patch
}
public static func < (lhs: InstanceFeatures.Version, rhs: InstanceFeatures.Version) -> Bool {
if lhs.major < rhs.major {
return true
} else if lhs.major > rhs.major {
return false
} else if lhs.minor < rhs.minor {
return true
} else if lhs.minor > rhs.minor {
return false
} else if lhs.patch < rhs.patch {
return true
} else {
return false
}
}
}
}

View File

@ -1,80 +0,0 @@
//
// Version.swift
// InstanceFeatures
//
// Created by Shadowfacts on 5/14/23.
//
import Foundation
@_spi(InstanceType) public struct Version: Equatable, Comparable, CustomStringConvertible {
private static let regex = try! NSRegularExpression(pattern: "^(\\d+)\\.(\\d+)\\.(\\d+).*$")
let major: Int
let minor: Int
let patch: Int
init(_ major: Int, _ minor: Int, _ patch: Int) {
self.major = major
self.minor = minor
self.patch = patch
}
init?(string: String) {
guard let match = Version.regex.firstMatch(in: string, range: NSRange(location: 0, length: string.utf16.count)),
match.numberOfRanges == 4 else {
return nil
}
let majorStr = (string as NSString).substring(with: match.range(at: 1))
let minorStr = (string as NSString).substring(with: match.range(at: 2))
let patchStr = (string as NSString).substring(with: match.range(at: 3))
guard let major = Int(majorStr),
let minor = Int(minorStr),
let patch = Int(patchStr) else {
return nil
}
self.major = major
self.minor = minor
self.patch = patch
}
public var description: String {
"\(major).\(minor).\(patch)"
}
public static func ==(lhs: Version, rhs: Version) -> Bool {
return lhs.major == rhs.major && lhs.minor == rhs.minor && lhs.patch == rhs.patch
}
public static func <(lhs: Version, rhs: Version) -> Bool {
if lhs.major < rhs.major {
return true
} else if lhs.major > rhs.major {
return false
} else if lhs.minor < rhs.minor {
return true
} else if lhs.minor > rhs.minor {
return false
} else if lhs.patch < rhs.patch {
return true
} else {
return false
}
}
}
func <(lhs: Version?, rhs: Version) -> Bool {
guard let lhs else {
// nil is less than or equal to everything
return true
}
return lhs < rhs
}
func >=(lhs: Version?, rhs: Version) -> Bool {
guard let lhs else {
// nil is less than or equal to everything
return false
}
return lhs >= rhs
}

View File

@ -539,7 +539,7 @@ extension Client {
self.type = type
}
public var errorDescription: String? {
public var localizedDescription: String {
switch type {
case .networkError(let error):
return "Network Error: \(error.localizedDescription)"

View File

@ -24,9 +24,7 @@ public final class CollapseState: Sendable {
}
public func copy() -> CollapseState {
let new = CollapseState(collapsible: self.collapsible, collapsed: self.collapsed)
new.statusPropertiesHash = self.statusPropertiesHash
return new
return CollapseState(collapsible: self.collapsible, collapsed: self.collapsed)
}
public func hash(into hasher: inout Hasher) {

View File

@ -12,8 +12,6 @@
<integer>4</integer>
<key>NSExtensionActivationSupportsMovieWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsText</key>
<true/>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
</dict>

View File

@ -71,43 +71,36 @@ class ShareViewController: UIViewController {
private func getDraftConfigurationFromExtensionContext() async -> (String, [DraftAttachment]) {
guard let extensionContext,
let inputItem = (extensionContext.inputItems as? [NSExtensionItem])?.first else {
let inputItem = (extensionContext.inputItems as? [NSExtensionItem])?.first,
let itemProvider = inputItem.attachments?.first else {
return ("", [])
}
var text: String = ""
var url: URL?
var attachments: [DraftAttachment] = []
for itemProvider in inputItem.attachments ?? [] {
if let attached: NSURL = await getObject(from: itemProvider) {
if url == nil {
url = attached as URL
if let url: NSURL = await getObject(from: itemProvider) {
if let title = inputItem.attributedTitle ?? inputItem.attributedContentText {
return ("\n\n\(title.string)\n\(url.absoluteString ?? "")", [])
} else {
return ("\n\n\(url.absoluteString ?? "")", [])
}
} else if let s: NSString = await getObject(from: itemProvider) {
if text.isEmpty {
text = s as String
}
} else if let attachment: DraftAttachment = await getObject(from: itemProvider) {
attachments.append(attachment)
} else if let text: NSString = await getObject(from: itemProvider) {
return ("\n\n\(text)", [])
} else if let attributedContent = inputItem.attributedContentText {
return ("\n\n\(attributedContent.string)", [])
} else {
let attachments = await withTaskGroup(of: DraftAttachment?.self, returning: [DraftAttachment].self) { group in
for provider in inputItem.attachments! {
group.addTask { @MainActor in
await self.getObject(from: provider)
}
}
if text.isEmpty,
let s = inputItem.attributedTitle ?? inputItem.attributedContentText {
text = s.string
return await group.reduce(into: [], { partialResult, result in
if let result {
partialResult.append(result)
}
if let url {
if !text.isEmpty {
text += "\n"
})
}
text += url.absoluteString
return ("", attachments)
}
if !text.isEmpty {
text = "\n\n\(text)"
}
return (text, attachments)
}
private func getObject<T: NSItemProviderReading>(from itemProvider: NSItemProvider) async -> T? {

View File

@ -246,7 +246,7 @@
D6B17255254F88B800128392 /* OppositeCollapseKeywordsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B17254254F88B800128392 /* OppositeCollapseKeywordsView.swift */; };
D6B22A0F2560D52D004D82EF /* TabbedPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */; };
D6B30E09254BAF63009CAEE5 /* ImageGrayscalifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B30E08254BAF63009CAEE5 /* ImageGrayscalifier.swift */; };
D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameView.swift */; };
D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */; };
D6B81F3C2560365300F6E31D /* RefreshableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B81F3B2560365300F6E31D /* RefreshableViewController.swift */; };
D6B81F442560390300F6E31D /* MenuController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B81F432560390300F6E31D /* MenuController.swift */; };
D6B8DB342182A59300424AF7 /* UIAlertController+Visibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */; };
@ -301,8 +301,6 @@
D6D79F2B2A0D5D5C00AB2315 /* StatusEditCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F2A2A0D5D5C00AB2315 /* StatusEditCollectionViewCell.swift */; };
D6D79F2D2A0D61B400AB2315 /* StatusEditContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F2C2A0D61B400AB2315 /* StatusEditContentTextView.swift */; };
D6D79F2F2A0D6A7F00AB2315 /* StatusEditPollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F2E2A0D6A7F00AB2315 /* StatusEditPollView.swift */; };
D6D79F532A0FFE3200AB2315 /* ToggleableButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F522A0FFE3200AB2315 /* ToggleableButton.swift */; };
D6D79F572A1160B800AB2315 /* AccountDisplayNameLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F562A1160B800AB2315 /* AccountDisplayNameLabel.swift */; };
D6D94955298963A900C59229 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D94954298963A900C59229 /* Colors.swift */; };
D6D9498D298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */; };
D6D9498F298EB79400C59229 /* CopyableLable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498E298EB79400C59229 /* CopyableLable.swift */; };
@ -639,7 +637,7 @@
D6B17254254F88B800128392 /* OppositeCollapseKeywordsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OppositeCollapseKeywordsView.swift; sourceTree = "<group>"; };
D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabbedPageViewController.swift; sourceTree = "<group>"; };
D6B30E08254BAF63009CAEE5 /* ImageGrayscalifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGrayscalifier.swift; sourceTree = "<group>"; };
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDisplayNameView.swift; sourceTree = "<group>"; };
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDisplayNameLabel.swift; sourceTree = "<group>"; };
D6B81F3B2560365300F6E31D /* RefreshableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshableViewController.swift; sourceTree = "<group>"; };
D6B81F432560390300F6E31D /* MenuController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuController.swift; sourceTree = "<group>"; };
D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIAlertController+Visibility.swift"; sourceTree = "<group>"; };
@ -702,8 +700,6 @@
D6D79F2A2A0D5D5C00AB2315 /* StatusEditCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditCollectionViewCell.swift; sourceTree = "<group>"; };
D6D79F2C2A0D61B400AB2315 /* StatusEditContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditContentTextView.swift; sourceTree = "<group>"; };
D6D79F2E2A0D6A7F00AB2315 /* StatusEditPollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditPollView.swift; sourceTree = "<group>"; };
D6D79F522A0FFE3200AB2315 /* ToggleableButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleableButton.swift; sourceTree = "<group>"; };
D6D79F562A1160B800AB2315 /* AccountDisplayNameLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDisplayNameLabel.swift; sourceTree = "<group>"; };
D6D94954298963A900C59229 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = "<group>"; };
D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusCollectionViewCell.swift; sourceTree = "<group>"; };
D6D9498E298EB79400C59229 /* CopyableLable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableLable.swift; sourceTree = "<group>"; };
@ -1353,7 +1349,7 @@
D6BED1722126661300F02DA0 /* Views */ = {
isa = PBXGroup;
children = (
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameView.swift */,
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */,
D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */,
D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */,
D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */,
@ -1375,7 +1371,6 @@
D65B4B69297777D900DABDFB /* StatusNotFoundView.swift */,
04ED00B021481ED800567C53 /* SteppedProgressView.swift */,
D6093FB625BE0CF3004811E6 /* TrendHistoryView.swift */,
D6D79F522A0FFE3200AB2315 /* ToggleableButton.swift */,
D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */,
D627944623A6AC9300D38C68 /* BasicTableViewCell.xib */,
D6A3BC872321F78000FD64D5 /* Account Cell */,
@ -1388,7 +1383,6 @@
D641C78B213DD92F004B4513 /* Profile Header */,
D641C78A213DD926004B4513 /* Status */,
D64AAE8F26C80DB600FC57FB /* Toast */,
D6D79F562A1160B800AB2315 /* AccountDisplayNameLabel.swift */,
);
path = Views;
sourceTree = "<group>";
@ -1951,7 +1945,6 @@
D6BC9DB1232C61BC002CA326 /* NotificationsPageViewController.swift in Sources */,
D6ADB6EC28EA73CB009924AB /* StatusContentContainer.swift in Sources */,
D6969E9E240C81B9002843CE /* NSTextAttachment+Emoji.swift in Sources */,
D6D79F532A0FFE3200AB2315 /* ToggleableButton.swift in Sources */,
D6BC9DB3232D4C07002CA326 /* WellnessPrefsView.swift in Sources */,
D6ADB6F028ED1F25009924AB /* CachedImageView.swift in Sources */,
D693DE5923FE24310061E07D /* InteractivePushTransition.swift in Sources */,
@ -2016,7 +2009,7 @@
D646C956213B365700269FB5 /* LargeImageExpandAnimationController.swift in Sources */,
D646DCD22A06F2510059ECEB /* NotificationsCollectionViewController.swift in Sources */,
D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */,
D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameView.swift in Sources */,
D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */,
D63D8DF42850FE7A008D95E1 /* ViewTags.swift in Sources */,
D61DC84B28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift in Sources */,
D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */,
@ -2112,7 +2105,6 @@
D663626C21361C6700C9CBA2 /* Account+Preferences.swift in Sources */,
D67895C0246870DE00D4CD9E /* LocalAccountAvatarView.swift in Sources */,
D6ADB6E828E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift in Sources */,
D6D79F572A1160B800AB2315 /* AccountDisplayNameLabel.swift in Sources */,
D646DCAC2A06C8840059ECEB /* ProfileFieldValueView.swift in Sources */,
D6F4D79429ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift in Sources */,
D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */,
@ -2378,7 +2370,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 91;
CURRENT_PROJECT_VERSION = 89;
INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2444,7 +2436,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 91;
CURRENT_PROJECT_VERSION = 89;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2470,7 +2462,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 91;
CURRENT_PROJECT_VERSION = 89;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
@ -2499,7 +2491,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 91;
CURRENT_PROJECT_VERSION = 89;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
@ -2528,7 +2520,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 91;
CURRENT_PROJECT_VERSION = 89;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
@ -2683,7 +2675,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 91;
CURRENT_PROJECT_VERSION = 89;
INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2714,7 +2706,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 91;
CURRENT_PROJECT_VERSION = 89;
INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2820,7 +2812,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 91;
CURRENT_PROJECT_VERSION = 89;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2846,7 +2838,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 91;
CURRENT_PROJECT_VERSION = 89;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;

View File

@ -1,105 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
wasCreatedForAppExtension = "YES"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D6A4531229EF64BA00032932"
BuildableName = "ShareExtension.appex"
BlueprintName = "ShareExtension"
ReferencedContainer = "container:Tusker.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D6D4DDCB212518A000E1C4BB"
BuildableName = "Tusker.app"
BlueprintName = "Tusker"
ReferencedContainer = "container:Tusker.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "1"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D6A4531229EF64BA00032932"
BuildableName = "ShareExtension.appex"
BlueprintName = "ShareExtension"
ReferencedContainer = "container:Tusker.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D6D4DDCB212518A000E1C4BB"
BuildableName = "Tusker.app"
BlueprintName = "Tusker"
ReferencedContainer = "container:Tusker.xcodeproj">
</BuildableReference>
</MacroExpansion>
<AdditionalOptions>
<AdditionalOption
key = "MallocStackLogging"
value = ""
isEnabled = "YES">
</AdditionalOption>
</AdditionalOptions>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<RemoteRunnable
runnableDebuggingMode = "1"
BundleIdentifier = "com.apple.mobileslideshow"
RemotePath = "/Applications/MobileSlideShow.app">
</RemoteRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -88,10 +88,6 @@
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
isEnabled = "YES">
</CommandLineArgument>
<CommandLineArgument
argument = "-com.apple.CoreData.CloudKitDebug 0"
isEnabled = "YES">
</CommandLineArgument>
<CommandLineArgument
argument = "-UIFocusLoggingEnabled YES"
isEnabled = "NO">

View File

@ -37,11 +37,6 @@ class ImageCache {
completion?(entry.data, entry.image)
return nil
} else {
return getFromSource(url, completion: completion)
}
}
func getFromSource(_ url: URL, completion: ((Data?, UIImage?) -> Void)?) -> Request? {
return Task.detached(priority: .userInitiated) {
let result = await self.fetch(url: url)
switch result {
@ -54,6 +49,7 @@ class ImageCache {
}
}
}
}
func get(_ url: URL, loadOriginal: Bool = false) async -> (Data?, UIImage?) {
if !ImageCache.disableCaching,

View File

@ -10,7 +10,6 @@ import UIKit
extension NSTextAttachment {
// Based on https://github.com/ReticentJohn/Amaroq/blob/7c5b7088eb9fd1611dcb0f47d43bf8df093e142c/DireFloof/InlineImageHelpers.m
@available(iOS, deprecated: 15.0)
convenience init(emojiImage image: UIImage, in font: UIFont, with textColor: UIColor = .label) {
let adjustedCapHeight = font.capHeight - 1
var imageSizeMatchingFontSize = CGSize(width: image.size.width * (adjustedCapHeight / image.size.height), height: adjustedCapHeight)

View File

@ -19,7 +19,7 @@ extension StatusEdit: CollapseStateResolving {}
extension CollapseState {
func resolveFor(status: CollapseStateResolving, height: () -> CGFloat, textLength: Int? = nil) -> Bool {
let newHash = hashStatusProperties(status: status)
lazy var newHash = hashStatusProperties(status: status)
guard unknown || statusPropertiesHash != newHash else {
return false
}

View File

@ -13,7 +13,6 @@ protocol Configurable {
func configure(_ closure: (T) -> Void) -> T
}
extension Configurable where Self: UIView {
@inline(__always)
func configure(_ closure: (Self) -> Void) -> Self {
closure(self)
return self

View File

@ -9,7 +9,7 @@
import UIKit
struct ImageGrayscalifier {
static let queue = DispatchQueue(label: "ImageGrayscalifier", qos: .userInitiated)
static let queue = DispatchQueue(label: "ImageGrayscalifier", qos: .default)
private static let context = CIContext()
private static let cache = NSCache<NSURL, UIImage>()
@ -24,17 +24,6 @@ struct ImageGrayscalifier {
}
}
static func convert(url: URL?, image: UIImage) -> UIImage? {
if let url,
let cached = cache.object(forKey: url as NSURL) {
return cached
}
guard let cgImage = image.cgImage else {
return nil
}
return doConvert(CIImage(cgImage: cgImage), url: url)
}
static func convert(url: URL?, data: Data) -> UIImage? {
if let url = url,
let cached = cache.object(forKey: url as NSURL) {

View File

@ -41,7 +41,7 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
fetchAvatar: { @MainActor in await ImageCache.avatars.get($0).1 },
fetchAttachment: { @MainActor in await ImageCache.attachments.get($0).1 },
fetchStatus: { mastodonController.persistentContainer.status(for: $0) },
displayNameLabel: { AnyView(AccountDisplayNameView(account: $0, textStyle: $1, emojiSize: $2)) },
displayNameLabel: { AnyView(AccountDisplayNameLabel(account: $0, textStyle: $1, emojiSize: $2)) },
replyContentView: { AnyView(ComposeReplyContentView(status: $0, mastodonController: mastodonController, heightChanged: $1)) },
emojiImageView: { AnyView(CustomEmojiImageView(emoji: $0)) }
)

View File

@ -147,7 +147,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
Item.status(id: node.status.id, node: node, state: .unknown, prevLink: index > 0, nextLink: true)
}
snapshot.appendItems(parentItems, toSection: .ancestors)
snapshot.reconfigureItems([mainStatusItem])
snapshot.reloadItems([mainStatusItem])
// convert sub-threads into items for section and add to snapshot
self.addFlattenedChildThreadsToSnapshot(tree.descendants, mainStatus: mainStatus, snapshot: &snapshot)

View File

@ -15,7 +15,7 @@ class FeaturedProfileCollectionViewCell: UICollectionViewCell {
@IBOutlet weak var headerImageView: UIImageView!
@IBOutlet weak var avatarContainerView: UIView!
@IBOutlet weak var avatarImageView: UIImageView!
@IBOutlet weak var displayNameLabel: AccountDisplayNameLabel!
@IBOutlet weak var displayNameLabel: EmojiLabel!
@IBOutlet weak var noteTextView: StatusContentTextView!
var account: Account?

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21507" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21678"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21505"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
@ -47,7 +47,7 @@
<constraint firstItem="4wd-wq-Sh2" firstAttribute="centerX" secondItem="RQe-uE-TEv" secondAttribute="centerX" id="bRk-uJ-JGg"/>
</constraints>
</view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Display name" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" minimumFontSize="10" adjustsLetterSpacingToFitWidth="YES" translatesAutoresizingMaskIntoConstraints="NO" id="voW-Is-1b2" customClass="AccountDisplayNameLabel" customModule="Tusker" customModuleProvider="target">
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Display name" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" minimumFontSize="10" adjustsLetterSpacingToFitWidth="YES" translatesAutoresizingMaskIntoConstraints="NO" id="voW-Is-1b2" customClass="EmojiLabel" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="76" y="72" width="316" height="24"/>
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="20"/>
<nil key="textColor"/>

View File

@ -21,7 +21,7 @@ class SuggestedProfileCardCollectionViewCell: UICollectionViewCell {
@IBOutlet weak var headerImageView: CachedImageView!
@IBOutlet weak var avatarContainerView: UIView!
@IBOutlet weak var avatarImageView: CachedImageView!
@IBOutlet weak var displayNameLabel: AccountDisplayNameLabel!
@IBOutlet weak var displayNameLabel: EmojiLabel!
@IBOutlet weak var usernameLabel: UILabel!
@IBOutlet weak var noteTextView: StatusContentTextView!
@IBOutlet weak var suggestionSourceButton: UIButton!

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21507" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21678"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21505"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="collection view cell content view" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
@ -44,7 +44,7 @@
<constraint firstAttribute="width" constant="90" id="wav-YT-e4Y"/>
</constraints>
</view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Display name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsLetterSpacingToFitWidth="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="XCk-sZ-ujT" customClass="AccountDisplayNameLabel" customModule="Tusker" customModuleProvider="target">
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Display name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsLetterSpacingToFitWidth="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="XCk-sZ-ujT" customClass="EmojiLabel" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="106" y="100" width="333" height="29"/>
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="24"/>
<nil key="textColor"/>

View File

@ -65,7 +65,7 @@ struct MuteAccountView: View {
)
VStack(alignment: .leading) {
AccountDisplayNameView(account: account, textStyle: .headline, emojiSize: 17)
AccountDisplayNameLabel(account: account, textStyle: .headline, emojiSize: 17)
Text("@\(account.acct)")
.fontWeight(.light)
.foregroundColor(.secondary)

View File

@ -55,7 +55,7 @@ struct ReportView: View {
)
VStack(alignment: .leading) {
AccountDisplayNameView(account: account, textStyle: .headline, emojiSize: 17)
AccountDisplayNameLabel(account: account, textStyle: .headline, emojiSize: 17)
Text("@\(account.acct)")
.fontWeight(.light)
.foregroundColor(.secondary)

View File

@ -117,8 +117,8 @@ class StatusEditCollectionViewCell: UICollectionViewListCell {
}
_ = state.resolveFor(status: edit, height: {
let width = self.bounds.width - 2*16
return contentContainer.estimateVisibleSubviewHeight(effectiveWidth: width)
layoutIfNeeded()
return contentContainer.visibleSubviewHeight
})
collapseButton.isHidden = !state.collapsible!
contentContainer.setCollapsed(state.collapsed!)

View File

@ -9,9 +9,7 @@
import UIKit
import Pachyderm
class StatusEditPollView: UIStackView, StatusContentPollView {
private var titleLabels: [EmojiLabel] = []
class StatusEditPollView: UIStackView {
init() {
super.init(frame: .zero)
@ -27,7 +25,6 @@ class StatusEditPollView: UIStackView, StatusContentPollView {
func updateUI(poll: StatusEdit.Poll?, emojis: [Emoji]) {
arrangedSubviews.forEach { $0.removeFromSuperview() }
titleLabels = []
for option in poll?.options ?? [] {
// the edit poll doesn't actually include the multiple value
@ -36,7 +33,6 @@ class StatusEditPollView: UIStackView, StatusContentPollView {
let label = EmojiLabel()
label.text = option.title
label.setEmojis(emojis, identifier: Optional<String>.none)
titleLabels.append(label)
let stack = UIStackView(arrangedSubviews: [
icon,
label,
@ -48,14 +44,4 @@ class StatusEditPollView: UIStackView, StatusContentPollView {
}
}
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat {
var height: CGFloat = 0
height += CGFloat(arrangedSubviews.count - 1) * 4
let labelWidth = effectiveWidth /* checkbox size: */ - 20 /* spacing: */ - 8
for titleLabel in titleLabels {
height += titleLabel.sizeThatFits(CGSize(width: labelWidth, height: UIView.layoutFittingCompressedSize.height)).height
}
return height
}
}

View File

@ -15,7 +15,7 @@ class AccountTableViewCell: UITableViewCell {
var mastodonController: MastodonController! { delegate?.apiController }
@IBOutlet weak var avatarImageView: UIImageView!
@IBOutlet weak var displayNameLabel: AccountDisplayNameLabel!
@IBOutlet weak var displayNameLabel: EmojiLabel!
@IBOutlet weak var usernameLabel: UILabel!
@IBOutlet weak var noteLabel: EmojiLabel!

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21507" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21678"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21505"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
@ -28,7 +28,7 @@
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="Iif-9m-vM5">
<rect key="frame" x="74" y="11" width="230" height="78"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Display Name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Fhc-bZ-lkB" customClass="AccountDisplayNameLabel" customModule="Tusker" customModuleProvider="target">
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Display Name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Fhc-bZ-lkB" customClass="EmojiLabel" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="230" height="20.5"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>

View File

@ -12,7 +12,7 @@ import Pachyderm
class LargeAccountDetailView: UIView {
var avatarImageView = UIImageView()
var displayNameLabel = AccountDisplayNameLabel()
var displayNameLabel = EmojiLabel()
var usernameLabel = UILabel()
var avatarRequest: ImageCache.Request?

View File

@ -2,31 +2,106 @@
// AccountDisplayNameLabel.swift
// Tusker
//
// Created by Shadowfacts on 5/14/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
// Created by Shadowfacts on 9/7/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import SwiftUI
import Pachyderm
import WebURLFoundationExtras
class AccountDisplayNameLabel: EmojiLabel {
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
private var accountID: String?
// store the display name, so that if it changes the label updates w/o changing the id
private var accountDisplayName: String?
struct AccountDisplayNameLabel: View {
let account: any AccountProtocol
let textStyle: Font.TextStyle
@ScaledMetric var emojiSize: CGFloat
@State var text: Text
@State var emojiRequests = [ImageCache.Request]()
func updateForAccountDisplayName(account: some AccountProtocol) {
guard accountID != account.id || accountDisplayName != account.displayName || Preferences.shared.hideCustomEmojiInUsernames == hasEmojis else {
return
init(account: any AccountProtocol, textStyle: Font.TextStyle, emojiSize: CGFloat) {
self.account = account
self.textStyle = textStyle
self._emojiSize = ScaledMetric(wrappedValue: emojiSize, relativeTo: textStyle)
let name = account.displayName.isEmpty ? account.username : account.displayName
self._text = State(initialValue: Text(verbatim: name))
}
accountID = account.id
accountDisplayName = account.displayName
self.text = accountDisplayName
if Preferences.shared.hideCustomEmojiInUsernames {
self.removeEmojis()
var body: some View {
text
.font(.system(textStyle).weight(.semibold))
.onAppear(perform: self.loadEmojis)
}
private func loadEmojis() {
let fullRange = NSRange(account.displayName.startIndex..., in: account.displayName)
let matches = emojiRegex.matches(in: account.displayName, options: [], range: fullRange)
guard !matches.isEmpty else { return }
let emojiImages = MultiThreadDictionary<String, Image>()
let group = DispatchGroup()
for emoji in account.emojis {
guard matches.contains(where: { (match) in
let matchShortcode = (account.displayName as NSString).substring(with: match.range(at: 1))
return emoji.shortcode == matchShortcode
}) else {
continue
}
group.enter()
let request = ImageCache.emojis.get(URL(emoji.url)!) { (_, image) in
defer { group.leave() }
guard let image = image else { return }
let size = CGSize(width: emojiSize, height: emojiSize)
let renderer = UIGraphicsImageRenderer(size: size)
let resized = renderer.image { (ctx) in
image.draw(in: CGRect(origin: .zero, size: size))
}
emojiImages[emoji.shortcode] = Image(uiImage: resized)
}
if let request = request {
emojiRequests.append(request)
}
}
group.notify(queue: .main) {
var text: Text?
var endIndex = account.displayName.utf16.count
// iterate backwards as to not alter the indices of earlier matches
for match in matches.reversed() {
let shortcode = (account.displayName as NSString).substring(with: match.range(at: 1))
guard let image = emojiImages[shortcode] else { continue }
let afterCurrentMatch = (account.displayName as NSString).substring(with: NSRange(location: match.range.upperBound, length: endIndex - match.range.upperBound))
if let subsequent = text {
text = Text(image) + Text(verbatim: afterCurrentMatch) + subsequent
} else {
self.setEmojis(account.emojis, identifier: account.id)
}
text = Text(image) + Text(verbatim: afterCurrentMatch)
}
endIndex = match.range.lowerBound
}
let beforeLastMatch = (account.displayName as NSString).substring(to: endIndex)
if let text = text {
self.text = Text(verbatim: beforeLastMatch) + text
} else {
self.text = Text(verbatim: beforeLastMatch)
}
}
}
}
//struct AccountDisplayNameLabel_Previews: PreviewProvider {
// static var previews: some View {
// AccountDisplayNameLabel()
// }
//}

View File

@ -1,107 +0,0 @@
//
// AccountDisplayNameView.swift
// Tusker
//
// Created by Shadowfacts on 9/7/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import SwiftUI
import Pachyderm
import WebURLFoundationExtras
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
struct AccountDisplayNameView: View {
let account: any AccountProtocol
let textStyle: Font.TextStyle
@ScaledMetric var emojiSize: CGFloat
@State var text: Text
@State var emojiRequests = [ImageCache.Request]()
init(account: any AccountProtocol, textStyle: Font.TextStyle, emojiSize: CGFloat) {
self.account = account
self.textStyle = textStyle
self._emojiSize = ScaledMetric(wrappedValue: emojiSize, relativeTo: textStyle)
let name = account.displayName.isEmpty ? account.username : account.displayName
self._text = State(initialValue: Text(verbatim: name))
}
var body: some View {
text
.font(.system(textStyle).weight(.semibold))
.onAppear(perform: self.loadEmojis)
}
private func loadEmojis() {
let fullRange = NSRange(account.displayName.startIndex..., in: account.displayName)
let matches = emojiRegex.matches(in: account.displayName, options: [], range: fullRange)
guard !matches.isEmpty else { return }
let emojiImages = MultiThreadDictionary<String, Image>()
let group = DispatchGroup()
for emoji in account.emojis {
guard matches.contains(where: { (match) in
let matchShortcode = (account.displayName as NSString).substring(with: match.range(at: 1))
return emoji.shortcode == matchShortcode
}) else {
continue
}
group.enter()
let request = ImageCache.emojis.get(URL(emoji.url)!) { (_, image) in
defer { group.leave() }
guard let image = image else { return }
let size = CGSize(width: emojiSize, height: emojiSize)
let renderer = UIGraphicsImageRenderer(size: size)
let resized = renderer.image { (ctx) in
image.draw(in: CGRect(origin: .zero, size: size))
}
emojiImages[emoji.shortcode] = Image(uiImage: resized)
}
if let request = request {
emojiRequests.append(request)
}
}
group.notify(queue: .main) {
var text: Text?
var endIndex = account.displayName.utf16.count
// iterate backwards as to not alter the indices of earlier matches
for match in matches.reversed() {
let shortcode = (account.displayName as NSString).substring(with: match.range(at: 1))
guard let image = emojiImages[shortcode] else { continue }
let afterCurrentMatch = (account.displayName as NSString).substring(with: NSRange(location: match.range.upperBound, length: endIndex - match.range.upperBound))
if let subsequent = text {
text = Text(image) + Text(verbatim: afterCurrentMatch) + subsequent
} else {
text = Text(image) + Text(verbatim: afterCurrentMatch)
}
endIndex = match.range.lowerBound
}
let beforeLastMatch = (account.displayName as NSString).substring(to: endIndex)
if let text = text {
self.text = Text(verbatim: beforeLastMatch) + text
} else {
self.text = Text(verbatim: beforeLastMatch)
}
}
}
}
//struct AccountDisplayNameLabel_Previews: PreviewProvider {
// static var previews: some View {
// AccountDisplayNameView()
// }
//}

View File

@ -75,8 +75,10 @@ class AttachmentView: GIFImageView {
gifPlaybackModeChanged()
if isGrayscale != Preferences.shared.grayscaleImages {
ImageGrayscalifier.queue.async {
self.displayImage()
}
}
if getBadges().isEmpty != Preferences.shared.showAttachmentBadges {
createBadgesView(getBadges())
@ -187,59 +189,53 @@ class AttachmentView: GIFImageView {
func loadImage() {
let attachmentURL = attachment.url
attachmentRequest = ImageCache.attachments.get(attachmentURL) { [weak self] (data, image) in
guard let self = self,
self.attachment.url == attachmentURL else {
return
}
attachmentRequest = ImageCache.attachments.get(attachmentURL) { [weak self] (data, _) in
guard let self = self, let data = data else { return }
DispatchQueue.main.async {
self.attachmentRequest = nil
if attachmentURL.pathExtension == "gif",
let data {
self.source = .gifData(attachmentURL, data, image)
if self.autoplayGifs {
}
if self.attachment.url.pathExtension == "gif" {
self.source = .gifData(attachmentURL, data)
let controller = GIFController(gifData: data)
DispatchQueue.main.async {
controller.attach(to: self)
if self.autoplayGifs {
controller.startAnimating()
}
}
if !self.autoplayGifs {
self.displayImage()
}
} else {
self.source = .imageData(attachmentURL, data)
self.displayImage()
}
} else if let image {
self.source = .image(attachmentURL, image)
self.displayImage()
}
}
}
}
func loadVideo() {
if let previewURL = self.attachment.previewURL {
attachmentRequest = ImageCache.attachments.get(previewURL, completion: { [weak self] (_, image) in
guard let self, let image else { return }
attachmentRequest = ImageCache.attachments.get(previewURL, completion: { [weak self] (data, _ )in
guard let self = self, let data = data else { return }
DispatchQueue.main.async {
self.attachmentRequest = nil
self.source = .image(previewURL, image)
self.source = .imageData(previewURL, data)
self.displayImage()
}
})
} else {
let attachmentURL = self.attachment.url
AttachmentView.queue.async { [weak self] in
AttachmentView.queue.async {
let asset = AVURLAsset(url: attachmentURL)
let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true
guard let image = try? generator.copyCGImage(at: .zero, actualTime: nil) else { return }
UIImage(cgImage: image).prepareForDisplay { [weak self] image in
DispatchQueue.main.async { [weak self] in
guard let self, let image else { return }
self.source = .image(attachmentURL, image)
self.source = .cgImage(attachmentURL, image)
self.displayImage()
}
}
}
}
let playImageView = UIImageView(image: UIImage(systemName: "play.circle.fill"))
playImageView.translatesAutoresizingMaskIntoConstraints = false
@ -280,11 +276,9 @@ class AttachmentView: GIFImageView {
let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true
guard let image = try? generator.copyCGImage(at: .zero, actualTime: nil) else { return }
DispatchQueue.main.async {
self.source = .cgImage(attachmentURL, image)
self.displayImage()
}
}
let gifvView = GifvAttachmentView(asset: asset, gravity: .resizeAspectFill)
self.gifvView = gifvView
@ -301,51 +295,33 @@ class AttachmentView: GIFImageView {
])
}
@MainActor
private func displayImage() {
isGrayscale = Preferences.shared.grayscaleImages
let image: UIImage?
switch source {
case nil:
self.image = nil
image = nil
case let .image(url, sourceImage):
case let .imageData(url, data), let .gifData(url, data):
if isGrayscale {
ImageGrayscalifier.queue.async { [weak self] in
let grayscale = ImageGrayscalifier.convert(url: url, image: sourceImage)
DispatchQueue.main.async { [weak self] in
self?.image = grayscale
}
}
image = ImageGrayscalifier.convert(url: url, data: data)
} else {
self.image = sourceImage
}
case let .gifData(url, _, sourceImage):
if isGrayscale,
let sourceImage {
ImageGrayscalifier.queue.async { [weak self] in
let grayscale = ImageGrayscalifier.convert(url: url, image: sourceImage)
DispatchQueue.main.async { [weak self] in
self?.image = grayscale
}
}
} else {
self.image = sourceImage
image = UIImage(data: data)
}
case let .cgImage(url, cgImage):
if isGrayscale {
ImageGrayscalifier.queue.async { [weak self] in
let grayscale = ImageGrayscalifier.convert(url: url, cgImage: cgImage)
DispatchQueue.main.async { [weak self] in
self?.image = grayscale
}
}
image = ImageGrayscalifier.convert(url: url, cgImage: cgImage)
} else {
image = UIImage(cgImage: cgImage)
}
}
DispatchQueue.main.async {
self.image = image
}
}
private func createBadgesView(_ badges: Badges) {
@ -433,8 +409,8 @@ class AttachmentView: GIFImageView {
fileprivate extension AttachmentView {
enum Source {
case image(URL, UIImage)
case gifData(URL, Data, UIImage?)
case imageData(URL, Data)
case gifData(URL, Data)
case cgImage(URL, CGImage)
}

View File

@ -22,15 +22,6 @@ class AttachmentsContainerView: UIView {
let attachmentStacks: NSHashTable<UIStackView> = .weakObjects()
var moreView: UIView?
private var aspectRatioConstraint: NSLayoutConstraint?
private(set) var aspectRatio: CGFloat = 16/9 {
didSet {
if aspectRatio != aspectRatioConstraint?.multiplier {
aspectRatioConstraint?.isActive = false
aspectRatioConstraint = widthAnchor.constraint(equalTo: heightAnchor, multiplier: aspectRatio)
aspectRatioConstraint!.isActive = true
}
}
}
var blurView: UIVisualEffectView?
var hideButtonView: UIVisualEffectView?
@ -102,8 +93,7 @@ class AttachmentsContainerView: UIView {
fillView(attachmentView)
sendSubviewToBack(attachmentView)
accessibilityElements.append(attachmentView)
if Preferences.shared.showUncroppedMediaInline,
let attachmentAspectRatio = attachmentView.attachmentAspectRatio {
if let attachmentAspectRatio = attachmentView.attachmentAspectRatio {
aspectRatio = attachmentAspectRatio
}
case 2:
@ -276,7 +266,18 @@ class AttachmentsContainerView: UIView {
accessibilityElements.append(moreView)
}
self.aspectRatio = aspectRatio
if Preferences.shared.showUncroppedMediaInline {
if aspectRatioConstraint?.multiplier != aspectRatio {
aspectRatioConstraint?.isActive = false
aspectRatioConstraint = widthAnchor.constraint(equalTo: heightAnchor, multiplier: aspectRatio)
aspectRatioConstraint!.isActive = true
}
} else {
if aspectRatioConstraint == nil {
aspectRatioConstraint = widthAnchor.constraint(equalTo: heightAnchor, multiplier: 16/9)
aspectRatioConstraint!.isActive = true
}
}
} else {
self.isHidden = true
}

View File

@ -40,16 +40,6 @@ extension BaseEmojiLabel {
return
}
// Based on https://github.com/ReticentJohn/Amaroq/blob/7c5b7088eb9fd1611dcb0f47d43bf8df093e142c/DireFloof/InlineImageHelpers.m
let adjustedCapHeight = emojiFont.capHeight - 1
func emojiImageSize(_ image: UIImage) -> CGSize {
var imageSizeMatchingFontSize = CGSize(width: image.size.width * (adjustedCapHeight / image.size.height), height: adjustedCapHeight)
var scale: CGFloat = 1.4
scale *= UIScreen.main.scale
imageSizeMatchingFontSize = CGSize(width: imageSizeMatchingFontSize.width * scale, height: imageSizeMatchingFontSize.height * scale)
return imageSizeMatchingFontSize
}
let emojiImages = MultiThreadDictionary<String, UIImage>()
var foundEmojis = false
@ -67,36 +57,22 @@ extension BaseEmojiLabel {
foundEmojis = true
if let image = ImageCache.emojis.get(URL(emoji.url)!)?.image {
// if the image is cached, add it immediately.
// we generate the thumbnail on the main thread, because it's usually fast enough
// and the delay caused by doing it asynchronously looks works.
// todo: consider caching these somewhere? the cache needs to take into account both the url and the font size, which may vary across labels, so can't just use ImageCache
if let thumbnail = image.preparingThumbnail(of: emojiImageSize(image)),
let cgImage = thumbnail.cgImage {
// the thumbnail API takes a pixel size and returns an image with scale 1, but we want the actual screen scale, so convert
// see FB12187798
emojiImages[emoji.shortcode] = UIImage(cgImage: cgImage, scale: UIScreen.main.scale, orientation: .up)
}
// if the image is cached, add it immediately
emojiImages[emoji.shortcode] = image
} else {
// otherwise, perform the network request
group.enter()
let request = ImageCache.emojis.getFromSource(URL(emoji.url)!) { (_, image) in
guard let image else {
group.leave()
return
}
image.prepareThumbnail(of: emojiImageSize(image)) { thumbnail in
guard let thumbnail = thumbnail?.cgImage,
case let rescaled = UIImage(cgImage: thumbnail, scale: UIScreen.main.scale, orientation: .up),
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: URL(emoji.url)!, image: rescaled) else {
// todo: ImageCache.emojis.get here will re-check the memory and disk caches, there should be another method to force-refetch
let request = ImageCache.emojis.get(URL(emoji.url)!) { (_, image) in
guard let image = image,
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: URL(emoji.url)!, image: image) else {
group.leave()
return
}
emojiImages[emoji.shortcode] = transformedImage
group.leave()
}
}
if let request = request {
emojiRequests.append(request)
}
@ -115,8 +91,10 @@ extension BaseEmojiLabel {
// even though the closures is invoked on the same thread that withLock is called, so it's unclear why it needs to be @Sendable (FB11494878)
// so, just ignore the warnings
let emojiAttachments = emojiImages.withLock {
let emojiFont = self.emojiFont
let emojiTextColor = self.emojiTextColor
return $0.mapValues { image in
NSTextAttachment(image: image)
NSTextAttachment(emojiImage: image, in: emojiFont, with: emojiTextColor)
}
}
let placeholder = usePlaceholders ? NSTextAttachment(emojiPlaceholderIn: self.emojiFont) : nil

View File

@ -18,7 +18,7 @@ class CachedImageView: UIImageView {
override var image: UIImage? {
didSet {
blurHashTask?.cancel()
fetchTask?.cancel()
}
}

View File

@ -52,7 +52,7 @@ class ConfirmReblogStatusPreviewView: UIView {
vStack.setContentHuggingPriority(.defaultLow, for: .horizontal)
hStack.addArrangedSubview(vStack)
let displayNameLabel = AccountDisplayNameLabel()
let displayNameLabel = EmojiLabel()
displayNameLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .caption1).addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.semibold]]), size: 0)
displayNameLabel.adjustsFontSizeToFitWidth = true
displayNameLabel.adjustsFontForContentSizeCategory = true

View File

@ -38,3 +38,24 @@ class EmojiLabel: UILabel, BaseEmojiLabel {
}
}
extension EmojiLabel {
func updateForAccountDisplayName(account: Account) {
if Preferences.shared.hideCustomEmojiInUsernames {
self.text = account.displayName
self.removeEmojis()
} else {
self.text = account.displayName
self.setEmojis(account.emojis, identifier: account.id)
}
}
func updateForAccountDisplayName(account: AccountMO) {
if Preferences.shared.hideCustomEmojiInUsernames {
self.text = account.displayNameWithoutCustomEmoji
self.removeEmojis()
} else {
self.text = account.displayOrUserName
self.setEmojis(account.emojis, identifier: account.id)
}
}
}

View File

@ -11,27 +11,24 @@ import Pachyderm
class PollOptionView: UIView {
private static let unselectedBackgroundColor = UIColor(white: 0.5, alpha: 0.25)
private let unselectedBackgroundColor = UIColor(white: 0.5, alpha: 0.25)
private(set) var label: EmojiLabel!
private(set) var checkbox: PollOptionCheckboxView?
let checkbox: PollOptionCheckboxView
init(poll: Poll, option: Poll.Option, mastodonController: MastodonController) {
checkbox = PollOptionCheckboxView(multiple: poll.multiple)
super.init(frame: .zero)
let minHeight: CGFloat = 35
layer.cornerRadius = 0.1 * minHeight
layer.cornerCurve = .continuous
backgroundColor = PollOptionView.unselectedBackgroundColor
backgroundColor = unselectedBackgroundColor
let showCheckbox = poll.ownVotes?.isEmpty == false || !poll.effectiveExpired
if showCheckbox {
checkbox = PollOptionCheckboxView(multiple: poll.multiple)
checkbox!.translatesAutoresizingMaskIntoConstraints = false
addSubview(checkbox!)
}
checkbox.translatesAutoresizingMaskIntoConstraints = false
addSubview(checkbox)
label = EmojiLabel()
let label = EmojiLabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.numberOfLines = 0
label.font = .preferredFont(forTextStyle: .callout)
@ -91,8 +88,12 @@ class PollOptionView: UIView {
NSLayoutConstraint.activate([
minHeightConstraint,
checkbox.centerYAnchor.constraint(equalTo: centerYAnchor),
checkbox.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
label.topAnchor.constraint(equalTo: topAnchor, constant: 4),
label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4),
label.leadingAnchor.constraint(equalTo: checkbox.trailingAnchor, constant: 8),
label.trailingAnchor.constraint(equalTo: percentLabel.leadingAnchor, constant: -4),
percentLabel.topAnchor.constraint(equalTo: topAnchor),
@ -100,16 +101,6 @@ class PollOptionView: UIView {
percentLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
])
if let checkbox {
NSLayoutConstraint.activate([
checkbox.centerYAnchor.constraint(equalTo: centerYAnchor),
checkbox.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
label.leadingAnchor.constraint(equalTo: checkbox.trailingAnchor, constant: 8),
])
} else {
label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8).isActive = true
}
isAccessibilityElement = true
}

View File

@ -14,7 +14,7 @@ class PollOptionsView: UIControl {
var mastodonController: MastodonController!
var checkedOptionIndices: [Int] {
options.enumerated().filter { $0.element.checkbox?.isChecked == true }.map(\.offset)
options.enumerated().filter { $0.element.checkbox.isChecked }.map(\.offset)
}
var checkedOptionsChanged: (() -> Void)?
@ -32,7 +32,7 @@ class PollOptionsView: UIControl {
override var isEnabled: Bool {
didSet {
options.forEach { $0.checkbox?.readOnly = !isEnabled }
options.forEach { $0.checkbox.readOnly = !isEnabled }
}
}
@ -65,11 +65,9 @@ class PollOptionsView: UIControl {
options = poll.options.enumerated().map { (index, opt) in
let optionView = PollOptionView(poll: poll, option: opt, mastodonController: mastodonController)
if let checkbox = optionView.checkbox {
checkbox.readOnly = !isEnabled
checkbox.isChecked = poll.ownVotes?.contains(index) ?? false
checkbox.voted = poll.voted ?? false
}
optionView.checkbox.readOnly = !isEnabled
optionView.checkbox.isChecked = poll.ownVotes?.contains(index) ?? false
optionView.checkbox.voted = poll.voted ?? false
stack.addArrangedSubview(optionView)
return optionView
}
@ -77,25 +75,15 @@ class PollOptionsView: UIControl {
accessibilityElements = options
}
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat {
var height: CGFloat = 0
height += CGFloat(options.count - 1) * stack.spacing
for option in options {
// this isn't the actual width, but it's close enough for the estimate
height += option.label.sizeThatFits(CGSize(width: effectiveWidth, height: UIView.layoutFittingCompressedSize.height)).height
}
return height
}
private func selectOption(_ option: PollOptionView) {
if poll.multiple {
option.checkbox?.isChecked.toggle()
option.checkbox.isChecked.toggle()
} else {
for opt in options {
if opt === option {
opt.checkbox?.isChecked = true
opt.checkbox.isChecked = true
} else {
opt.checkbox?.isChecked = false
opt.checkbox.isChecked = false
}
}
}

View File

@ -9,7 +9,7 @@
import UIKit
import Pachyderm
class StatusPollView: UIView, StatusContentPollView {
class StatusPollView: UIView {
private static let formatter: DateComponentsFormatter = {
let f = DateComponentsFormatter()
@ -140,11 +140,6 @@ class StatusPollView: UIView, StatusContentPollView {
voteButton.isEnabled = false
}
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat {
guard let poll else { return 0 }
return optionsView.estimateHeight(effectiveWidth: effectiveWidth) + infoLabel.sizeThatFits(UIView.layoutFittingExpandedSize).height
}
private func checkedOptionsChanged() {
voteButton.isEnabled = optionsView.checkedOptionIndices.count > 0
}

View File

@ -28,7 +28,7 @@ class ProfileHeaderView: UIView {
@IBOutlet weak var avatarImageView: UIImageView!
@IBOutlet weak var moreButton: ProfileHeaderButton!
@IBOutlet weak var followButton: ProfileHeaderButton!
@IBOutlet weak var displayNameLabel: AccountDisplayNameLabel!
@IBOutlet weak var displayNameLabel: EmojiLabel!
@IBOutlet weak var usernameLabel: UILabel!
@IBOutlet weak var lockImageView: UIImageView!
@IBOutlet weak var vStack: UIStackView!

View File

@ -39,7 +39,7 @@
<constraint firstItem="TkY-oK-if4" firstAttribute="centerX" secondItem="wT9-2J-uSY" secondAttribute="centerX" id="ozz-sa-gSc"/>
</constraints>
</view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" text="Display name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumFontSize="10" adjustsLetterSpacingToFitWidth="YES" translatesAutoresizingMaskIntoConstraints="NO" id="vcl-Gl-kXl" customClass="AccountDisplayNameLabel" customModule="Tusker" customModuleProvider="target">
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" text="Display name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumFontSize="10" adjustsLetterSpacingToFitWidth="YES" translatesAutoresizingMaskIntoConstraints="NO" id="vcl-Gl-kXl" customClass="EmojiLabel" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="144" y="206" width="254" height="29"/>
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="24"/>
<nil key="textColor"/>

View File

@ -39,7 +39,7 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
$0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(accountPressed)))
}
let displayNameLabel = AccountDisplayNameLabel().configure {
let displayNameLabel = EmojiLabel().configure {
$0.font = UIFontMetrics(forTextStyle: .title1).scaledFont(for: .systemFont(ofSize: 24, weight: .semibold))
$0.adjustsFontForContentSizeCategory = true
}
@ -191,13 +191,13 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
$0.addInteraction(UIPointerInteraction(delegate: self))
}
private(set) lazy var favoriteButton = ToggleableButton(activeColor: UIColor(displayP3Red: 1, green: 0.8, blue: 0, alpha: 1)).configure {
private(set) lazy var favoriteButton = UIButton().configure {
$0.setImage(UIImage(systemName: "star.fill"), for: .normal)
$0.addTarget(self, action: #selector(favoritePressed), for: .touchUpInside)
$0.addInteraction(UIPointerInteraction(delegate: self))
}
private(set) lazy var reblogButton = ToggleableButton(activeColor: UIColor(displayP3Red: 1, green: 0.8, blue: 0, alpha: 1)).configure {
private(set) lazy var reblogButton = UIButton().configure {
$0.setImage(UIImage(systemName: "repeat"), for: .normal)
$0.addTarget(self, action: #selector(reblogPressed), for: .touchUpInside)
$0.addInteraction(UIPointerInteraction(delegate: self))
@ -348,6 +348,24 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
accountDetailAccessibilityElement.navigationDelegate = delegate
accountDetailAccessibilityElement.accountID = accountID
let metaButtonAttributes = AttributeContainer([
.font: ConversationMainStatusCollectionViewCell.metaFont
])
let favoritesFormat = NSLocalizedString("favorites count", comment: "conv main status favorites button label")
var favoritesConfig = UIButton.Configuration.plain()
favoritesConfig.baseForegroundColor = .secondaryLabel
favoritesConfig.attributedTitle = AttributedString(String.localizedStringWithFormat(favoritesFormat, status.favouritesCount), attributes: metaButtonAttributes)
favoritesConfig.contentInsets = .zero
favoritesCountButton.configuration = favoritesConfig
let reblogsFormat = NSLocalizedString("reblogs count", comment: "conv main status reblogs button label")
var reblogsConfig = UIButton.Configuration.plain()
reblogsConfig.baseForegroundColor = .secondaryLabel
reblogsConfig.attributedTitle = AttributedString(String.localizedStringWithFormat(reblogsFormat, status.reblogsCount), attributes: metaButtonAttributes)
reblogsConfig.contentInsets = .zero
reblogsCountButton.configuration = reblogsConfig
var timestampAndClientText = ConversationMainStatusCollectionViewCell.dateFormatter.string(from: status.createdAt)
if let application = status.applicationName {
timestampAndClientText += "\(application)"
@ -358,9 +376,7 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
editTimestampButton.isHidden = false
var config = UIButton.Configuration.plain()
config.baseForegroundColor = .secondaryLabel
config.attributedTitle = AttributedString("Edited on \(ConversationMainStatusCollectionViewCell.dateFormatter.string(from: editedAt))", attributes: AttributeContainer([
.font: ConversationMainStatusCollectionViewCell.metaFont
]))
config.attributedTitle = AttributedString("Edited on \(ConversationMainStatusCollectionViewCell.dateFormatter.string(from: editedAt))", attributes: metaButtonAttributes)
config.contentInsets = .zero
editTimestampButton.configuration = config
} else {
@ -376,33 +392,6 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
baseCreateObservers()
}
func updateStatusState(status: StatusMO) {
baseUpdateStatusState(status: status)
let attributes = AttributeContainer([
.font: ConversationMainStatusCollectionViewCell.metaFont
])
let favoritesFormat = NSLocalizedString("favorites count", comment: "conv main status favorites button label")
var favoritesConfig = UIButton.Configuration.plain()
favoritesConfig.baseForegroundColor = .secondaryLabel
favoritesConfig.attributedTitle = AttributedString(String.localizedStringWithFormat(favoritesFormat, status.favouritesCount), attributes: attributes)
favoritesConfig.contentInsets = .zero
favoritesCountButton.configuration = favoritesConfig
let reblogsFormat = NSLocalizedString("reblogs count", comment: "conv main status reblogs button label")
var reblogsConfig = UIButton.Configuration.plain()
reblogsConfig.baseForegroundColor = .secondaryLabel
reblogsConfig.attributedTitle = AttributedString(String.localizedStringWithFormat(reblogsFormat, status.reblogsCount), attributes: attributes)
reblogsConfig.contentInsets = .zero
reblogsCountButton.configuration = reblogsConfig
}
func estimateContentHeight() -> CGFloat {
let width = bounds.width - 2*16
return contentContainer.estimateVisibleSubviewHeight(effectiveWidth: width)
}
func updateUIForPreferences(status: StatusMO) {
baseUpdateUIForPreferences(status: status)
}

View File

@ -199,6 +199,38 @@ class StatusCardView: UIView {
}
}
private func loadBlurHash() {
guard let card = card, let hash = card.blurhash else { return }
AttachmentView.queue.async { [weak self] in
guard let self = self else { return }
let size: CGSize
if let width = card.width, let height = card.height {
let aspectRatio = CGFloat(width) / CGFloat(height)
if aspectRatio > 1 {
size = CGSize(width: 32, height: 32 / aspectRatio)
} else {
size = CGSize(width: 32 * aspectRatio, height: 32)
}
} else {
size = CGSize(width: 32, height: 32)
}
guard let preview = UIImage(blurHash: hash, size: size) else {
return
}
DispatchQueue.main.async { [weak self] in
guard let self = self,
self.card?.url == card.url,
self.imageView.image == nil else { return }
self.imageView.image = preview
}
}
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
hStack.backgroundColor = activeBackgroundColor
setNeedsDisplay()

View File

@ -20,14 +20,14 @@ protocol StatusCollectionViewCellDelegate: AnyObject, TuskerNavigationDelegate,
protocol StatusCollectionViewCell: UICollectionViewCell, AttachmentViewDelegate {
// MARK: Subviews
var avatarImageView: CachedImageView { get }
var displayNameLabel: AccountDisplayNameLabel { get }
var displayNameLabel: EmojiLabel { get }
var usernameLabel: UILabel { get }
var contentWarningLabel: EmojiLabel { get }
var collapseButton: StatusCollapseButton { get }
var contentContainer: StatusContentContainer<StatusContentTextView, StatusPollView> { get }
var replyButton: UIButton { get }
var favoriteButton: ToggleableButton { get }
var reblogButton: ToggleableButton { get }
var favoriteButton: UIButton { get }
var reblogButton: UIButton { get }
var moreButton: UIButton { get }
var prevThreadLinkView: UIView? { get set }
var nextThreadLinkView: UIView? { get set }
@ -45,8 +45,6 @@ protocol StatusCollectionViewCell: UICollectionViewCell, AttachmentViewDelegate
var cancellables: Set<AnyCancellable> { get set }
func updateUIForPreferences(status: StatusMO)
func updateStatusState(status: StatusMO)
func estimateContentHeight() -> CGFloat
}
// MARK: UI Configuration
@ -60,14 +58,8 @@ extension StatusCollectionViewCell {
.receive(on: DispatchQueue.main)
.filter { [unowned self] in $0 == self.statusID }
.sink { [unowned self] _ in
if let status = self.mastodonController.persistentContainer.status(for: self.statusID) {
// update immediately w/o animation
self.favoriteButton.active = status.favourited
self.reblogButton.active = status.reblogged
self.delegate?.statusCellNeedsReconfigure(self, animated: true, completion: nil)
}
}
.store(in: &cancellables)
mastodonController.persistentContainer.accountSubject
@ -106,7 +98,6 @@ extension StatusCollectionViewCell {
}
updateUIForPreferences(status: status)
updateStatusState(status: status)
contentWarningLabel.text = status.spoilerText
contentWarningLabel.isHidden = status.spoilerText.isEmpty
@ -114,17 +105,35 @@ extension StatusCollectionViewCell {
contentWarningLabel.setEmojis(status.emojis, identifier: statusID)
}
reblogButton.isEnabled = reblogEnabled(status: status)
replyButton.isEnabled = mastodonController.loggedIn
favoriteButton.isEnabled = mastodonController.loggedIn
let didResolve = statusState.resolveFor(status: status, height: self.estimateContentHeight)
// let didResolve = statusState.resolveFor(status: status) {
//// // layout so that we can take the content height into consideration when deciding whether to collapse
//// layoutIfNeeded()
//// return contentContainer.visibleSubviewHeight
// return contentContainer.estimateVisibleSubviewHeight(effectiveWidth: )
// }
favoriteButton.isEnabled = mastodonController.loggedIn
if status.favourited {
favoriteButton.tintColor = UIColor(displayP3Red: 1, green: 0.8, blue: 0, alpha: 1)
favoriteButton.accessibilityLabel = NSLocalizedString("Undo Favorite", comment: "undo favorite button accessibility label")
} else {
favoriteButton.tintColor = nil
favoriteButton.accessibilityLabel = NSLocalizedString("Favorite", comment: "favorite button accessibility label")
}
reblogButton.isEnabled = reblogEnabled(status: status)
if status.reblogged {
reblogButton.tintColor = UIColor(displayP3Red: 1, green: 0.8, blue: 0, alpha: 1)
reblogButton.accessibilityLabel = NSLocalizedString("Undo Reblog", comment: "undo reblog button accessibility label")
} else {
reblogButton.tintColor = nil
reblogButton.accessibilityLabel = NSLocalizedString("Reblog", comment: "reblog button accessibility label")
}
// keep menu in sync with changed states e.g. bookmarked, muted
// do not include reply action here, because the cell already contains a button for it
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForStatus(status, source: .view(moreButton), includeStatusButtonActions: false) ?? [])
let didResolve = statusState.resolveFor(status: status) {
// layout so that we can take the content height into consideration when deciding whether to collapse
layoutIfNeeded()
return contentContainer.visibleSubviewHeight
}
if didResolve {
if statusState.collapsible! && showStatusAutomatically {
statusState.collapsed = false
@ -148,9 +157,7 @@ extension StatusCollectionViewCell {
guard mastodonController.loggedIn else {
return false
}
if status.visibility == .direct {
return false
} else if status.visibility == .private {
if status.visibility == .direct || status.visibility == .private {
if mastodonController.instanceFeatures.boostToOriginalAudience,
status.account.id == mastodonController.account?.id {
return true
@ -205,30 +212,6 @@ extension StatusCollectionViewCell {
displayNameLabel.updateForAccountDisplayName(account: status.account)
}
func baseUpdateStatusState(status: StatusMO) {
favoriteButton.active = status.favourited
if status.favourited {
favoriteButton.accessibilityLabel = NSLocalizedString("Undo Favorite", comment: "undo favorite button accessibility label")
} else {
favoriteButton.accessibilityLabel = NSLocalizedString("Favorite", comment: "favorite button accessibility label")
}
reblogButton.active = status.reblogged
if status.reblogged {
reblogButton.accessibilityLabel = NSLocalizedString("Undo Reblog", comment: "undo reblog button accessibility label")
} else {
reblogButton.accessibilityLabel = NSLocalizedString("Reblog", comment: "reblog button accessibility label")
}
// keep menu in sync with changed states e.g. bookmarked, muted
// do not include reply action here, because the cell already contains a button for it
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForStatus(status, source: .view(moreButton), includeStatusButtonActions: false) ?? [])
contentContainer.pollView.isHidden = status.poll == nil
contentContainer.pollView.mastodonController = mastodonController
contentContainer.pollView.delegate = delegate
contentContainer.pollView.updateUI(status: status, poll: status.poll)
}
func setShowThreadLinks(prev: Bool, next: Bool) {
if prev {
if let prevThreadLinkView {

View File

@ -8,11 +8,7 @@
import UIKit
protocol StatusContentPollView: UIView {
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat
}
class StatusContentContainer<ContentView: ContentTextView, PollView: StatusContentPollView>: UIView {
class StatusContentContainer<ContentView: ContentTextView, PollView: UIView>: UIView {
private var useTopSpacer = false
private let topSpacer = UIView().configure {
@ -29,10 +25,9 @@ class StatusContentContainer<ContentView: ContentTextView, PollView: StatusConte
$0.isSelectable = false
}
private static var cardViewHeight: CGFloat { 90 }
let cardView = StatusCardView().configure {
NSLayoutConstraint.activate([
$0.heightAnchor.constraint(equalToConstant: StatusContentContainer.cardViewHeight),
$0.heightAnchor.constraint(equalToConstant: 90),
])
}
@ -130,30 +125,11 @@ class StatusContentContainer<ContentView: ContentTextView, PollView: StatusConte
}
isCollapsed = collapsed
// don't call setNeedsUpdateConstraints b/c that destroys/recreates a bunch of other constraints
// if there is no lastSubviewBottomConstraint, then we already need a constraint update, so we don't need to do anything here
if let lastSubviewBottomConstraint {
lastSubviewBottomConstraint.isActive = !collapsed
// ensure that we have a lastSubviewBottomConstraint
updateConstraintsIfNeeded()
// force unwrap because the content container should always have at least one view
lastSubviewBottomConstraint!.isActive = !collapsed
zeroHeightConstraint.isActive = collapsed
}
}
// used only for collapsing automatically based on height, doesn't need to be accurate
// just roughly inline with the content height
func estimateVisibleSubviewHeight(effectiveWidth: CGFloat) -> CGFloat {
var height: CGFloat = 0
height += contentTextView.sizeThatFits(CGSize(width: effectiveWidth, height: UIView.layoutFittingCompressedSize.height)).height
if !cardView.isHidden {
height += StatusContentContainer.cardViewHeight
}
if !attachmentsView.isHidden {
height += effectiveWidth / attachmentsView.aspectRatio
}
if !pollView.isHidden {
let pollHeight = pollView.estimateHeight(effectiveWidth: effectiveWidth)
height += pollHeight
}
return height
}
}

View File

@ -114,7 +114,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
$0.spacing = 4
}
let displayNameLabel = AccountDisplayNameLabel().configure {
let displayNameLabel = EmojiLabel().configure {
$0.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([
.traits: [
UIFontDescriptor.TraitKey.weight: UIFont.Weight.semibold.rawValue,
@ -238,13 +238,13 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
$0.addInteraction(UIPointerInteraction(delegate: self))
}
private(set) lazy var favoriteButton = ToggleableButton(activeColor: UIColor(displayP3Red: 1, green: 0.8, blue: 0, alpha: 1)).configure {
private(set) lazy var favoriteButton = UIButton().configure {
$0.setImage(UIImage(systemName: "star.fill"), for: .normal)
$0.addTarget(self, action: #selector(favoritePressed), for: .touchUpInside)
$0.addInteraction(UIPointerInteraction(delegate: self))
}
private(set) lazy var reblogButton = ToggleableButton(activeColor: UIColor(displayP3Red: 1, green: 0.8, blue: 0, alpha: 1)).configure {
private(set) lazy var reblogButton = UIButton().configure {
$0.setImage(UIImage(systemName: "repeat"), for: .normal)
$0.addTarget(self, action: #selector(reblogPressed), for: .touchUpInside)
$0.addInteraction(UIPointerInteraction(delegate: self))
@ -619,15 +619,10 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
metaIndicatorsView.updateUI(status: status)
timelineReasonIcon.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * Self.timelineReasonIconSize
if let rebloggerID,
let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {
updateRebloggerLabel(reblogger: reblogger)
}
func updateStatusState(status: StatusMO) {
baseUpdateStatusState(status: status)
}
func estimateContentHeight() -> CGFloat {
let width = bounds.width /* leading spacing: */ - 16 /* avatar: */ - 50 /* spacing: 8 */ - 8 /* trailing spacing: */ - 16
return contentContainer.estimateVisibleSubviewHeight(effectiveWidth: width)
}
private func updateTimestamp() {
@ -699,11 +694,6 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
updateUIForPreferences(status: status)
if let rebloggerID,
let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {
updateRebloggerLabel(reblogger: reblogger)
}
if isGrayscale != Preferences.shared.grayscaleImages {
updateGrayscaleableUI(status: status)
}

View File

@ -1,31 +0,0 @@
//
// ToggleableButton.swift
// Tusker
//
// Created by Shadowfacts on 5/13/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import UIKit
class ToggleableButton: UIButton {
let activeColor: UIColor
var active: Bool {
didSet {
tintColor = active ? activeColor : nil
}
}
init(activeColor: UIColor) {
self.activeColor = activeColor
self.active = false
super.init(frame: .zero)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}