Compare commits

..

20 Commits

Author SHA1 Message Date
Shadowfacts fc7e7f502b Bump build number and update changelog 2023-05-14 17:30:01 -04:00
Shadowfacts 38a2ebd32b Fix link card images not loading on Mastodon 2023-05-14 16:24:54 -04:00
Shadowfacts 3b965b92f2 Don't update constraints from StatusContentContainer.setCollapsed 2023-05-14 15:53:24 -04:00
Shadowfacts 421cb7ba03 Fix conversation main status flickering when context is loaded 2023-05-14 15:25:09 -04:00
Shadowfacts 8319935a3d BaseEmojiLabel improvements
Avoid rechecking disk/memory caches when fetching

Use UIImage thumbnail API, rather than UIGraphicsImageRenderer, and make
thumbnail off main thread when possible
2023-05-14 15:19:00 -04:00
Shadowfacts 91ef386a41 Fix reblogger label getting updated twice for every cell 2023-05-14 14:58:46 -04:00
Shadowfacts c8eec17180 Fix custom emoji in display name being replaced multiple times unnecessarily 2023-05-14 14:41:36 -04:00
Shadowfacts c94e60d49b Enable editing on Pleroma 2.5+ 2023-05-14 13:55:28 -04:00
Shadowfacts b00170c3f9 Move InstanceFeatures.Version to separate file 2023-05-14 13:51:41 -04:00
Shadowfacts b37e5fffbf Silence CloudKit debug logging 2023-05-13 15:03:48 -04:00
Shadowfacts 8c27a9368f Estimate height when resolving status collapse state 2023-05-13 15:00:03 -04:00
Shadowfacts 735659dee6 Don't leave space for checkbox when no checkboxes are shown 2023-05-13 14:14:38 -04:00
Shadowfacts bf02b185ed Fix StatusState copying removing cached state
Closes #380
2023-05-13 13:53:04 -04:00
Shadowfacts 4ccf5d21a4 Disable boost to original audience for the users own DMs
Closes #382
2023-05-13 13:50:07 -04:00
Shadowfacts 9ac1c43511 Update favorite/reblog button appearance immediately on tap
Fixes #381
2023-05-13 13:48:49 -04:00
Shadowfacts 76b9496fe6 Revert "Unseparate out updateStatusState method"
This reverts commit 2157126332.
2023-05-13 13:18:57 -04:00
Shadowfacts ae8191ca0e Don't use prepareThumbnail in Compose screen
Fixes crash when sharing certain images to share sheet extension
2023-05-13 12:38:51 -04:00
Shadowfacts a9a9bfebeb Fix share sheet extension not working with Apple News
Closes #375
2023-05-12 22:00:00 -04:00
Shadowfacts 2d8e2f0824 Fix hitches due to AttachmentView not using pre-prepared images 2023-05-12 21:40:17 -04:00
Shadowfacts 6f18d46037 Properly conform Client.Error to LocalizedError 2023-05-11 23:26:06 -04:00
48 changed files with 799 additions and 451 deletions

View File

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

View File

@ -88,7 +88,7 @@ public class InstanceFeatures: ObservableObject {
} }
public var needsWideColorGamutHack: Bool { public var needsWideColorGamutHack: Bool {
if case .mastodon(_, .some(let version)) = instanceType { if case .mastodon(_, let version) = instanceType {
return version < Version(4, 0, 0) return version < Version(4, 0, 0)
} else { } else {
return true return true
@ -116,8 +116,16 @@ public class InstanceFeatures: ObservableObject {
} }
public var editStatuses: Bool { public var editStatuses: Bool {
// todo: does this require a particular akkoma version? switch instanceType {
hasMastodonVersion(3, 5, 0) || instanceType.isPleroma(.akkoma(nil)) 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
}
} }
public var needsEditAttachmentsInSeparateRequest: Bool { public var needsEditAttachmentsInSeparateRequest: Bool {
@ -188,7 +196,7 @@ public class InstanceFeatures: ObservableObject {
} }
public func hasMastodonVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool { public func hasMastodonVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
if case .mastodon(_, .some(let version)) = instanceType { if case .mastodon(_, let version) = instanceType {
return version >= Version(major, minor, patch) return version >= Version(major, minor, patch)
} else { } else {
return false return false
@ -197,7 +205,7 @@ public class InstanceFeatures: ObservableObject {
func hasPleromaVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool { func hasPleromaVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
switch instanceType { switch instanceType {
case .pleroma(.vanilla(.some(let version))), .pleroma(.akkoma(.some(let version))): case .pleroma(.vanilla(let version)), .pleroma(.akkoma(let version)):
return version >= Version(major, minor, patch) return version >= Version(major, minor, patch)
default: default:
return false return false
@ -283,61 +291,3 @@ 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

@ -0,0 +1,80 @@
//
// 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 self.type = type
} }
public var localizedDescription: String { public var errorDescription: String? {
switch type { switch type {
case .networkError(let error): case .networkError(let error):
return "Network Error: \(error.localizedDescription)" return "Network Error: \(error.localizedDescription)"

View File

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

View File

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

View File

@ -71,36 +71,43 @@ class ShareViewController: UIViewController {
private func getDraftConfigurationFromExtensionContext() async -> (String, [DraftAttachment]) { private func getDraftConfigurationFromExtensionContext() async -> (String, [DraftAttachment]) {
guard let extensionContext, guard let extensionContext,
let inputItem = (extensionContext.inputItems as? [NSExtensionItem])?.first, let inputItem = (extensionContext.inputItems as? [NSExtensionItem])?.first else {
let itemProvider = inputItem.attachments?.first else {
return ("", []) return ("", [])
} }
if let url: NSURL = await getObject(from: itemProvider) { var text: String = ""
if let title = inputItem.attributedTitle ?? inputItem.attributedContentText { var url: URL?
return ("\n\n\(title.string)\n\(url.absoluteString ?? "")", []) var attachments: [DraftAttachment] = []
} else {
return ("\n\n\(url.absoluteString ?? "")", []) for itemProvider in inputItem.attachments ?? [] {
} if let attached: NSURL = await getObject(from: itemProvider) {
} else if let text: NSString = await getObject(from: itemProvider) { if url == nil {
return ("\n\n\(text)", []) url = attached as URL
} 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)
}
} }
} else if let s: NSString = await getObject(from: itemProvider) {
return await group.reduce(into: [], { partialResult, result in if text.isEmpty {
if let result { text = s as String
partialResult.append(result) }
} } else if let attachment: DraftAttachment = await getObject(from: itemProvider) {
}) attachments.append(attachment)
} }
return ("", attachments)
} }
if text.isEmpty,
let s = inputItem.attributedTitle ?? inputItem.attributedContentText {
text = s.string
}
if let url {
if !text.isEmpty {
text += "\n"
}
text += url.absoluteString
}
if !text.isEmpty {
text = "\n\n\(text)"
}
return (text, attachments)
} }
private func getObject<T: NSItemProviderReading>(from itemProvider: NSItemProvider) async -> T? { 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 */; }; D6B17255254F88B800128392 /* OppositeCollapseKeywordsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B17254254F88B800128392 /* OppositeCollapseKeywordsView.swift */; };
D6B22A0F2560D52D004D82EF /* TabbedPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */; }; D6B22A0F2560D52D004D82EF /* TabbedPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */; };
D6B30E09254BAF63009CAEE5 /* ImageGrayscalifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B30E08254BAF63009CAEE5 /* ImageGrayscalifier.swift */; }; D6B30E09254BAF63009CAEE5 /* ImageGrayscalifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B30E08254BAF63009CAEE5 /* ImageGrayscalifier.swift */; };
D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */; }; D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameView.swift */; };
D6B81F3C2560365300F6E31D /* RefreshableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B81F3B2560365300F6E31D /* RefreshableViewController.swift */; }; D6B81F3C2560365300F6E31D /* RefreshableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B81F3B2560365300F6E31D /* RefreshableViewController.swift */; };
D6B81F442560390300F6E31D /* MenuController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B81F432560390300F6E31D /* MenuController.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 */; }; D6B8DB342182A59300424AF7 /* UIAlertController+Visibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */; };
@ -301,6 +301,8 @@
D6D79F2B2A0D5D5C00AB2315 /* StatusEditCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F2A2A0D5D5C00AB2315 /* StatusEditCollectionViewCell.swift */; }; D6D79F2B2A0D5D5C00AB2315 /* StatusEditCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F2A2A0D5D5C00AB2315 /* StatusEditCollectionViewCell.swift */; };
D6D79F2D2A0D61B400AB2315 /* StatusEditContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F2C2A0D61B400AB2315 /* StatusEditContentTextView.swift */; }; D6D79F2D2A0D61B400AB2315 /* StatusEditContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F2C2A0D61B400AB2315 /* StatusEditContentTextView.swift */; };
D6D79F2F2A0D6A7F00AB2315 /* StatusEditPollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F2E2A0D6A7F00AB2315 /* StatusEditPollView.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 */; }; D6D94955298963A900C59229 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D94954298963A900C59229 /* Colors.swift */; };
D6D9498D298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */; }; D6D9498D298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */; };
D6D9498F298EB79400C59229 /* CopyableLable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498E298EB79400C59229 /* CopyableLable.swift */; }; D6D9498F298EB79400C59229 /* CopyableLable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498E298EB79400C59229 /* CopyableLable.swift */; };
@ -637,7 +639,7 @@
D6B17254254F88B800128392 /* OppositeCollapseKeywordsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OppositeCollapseKeywordsView.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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 /* AccountDisplayNameView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDisplayNameView.swift; sourceTree = "<group>"; };
D6B81F3B2560365300F6E31D /* RefreshableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshableViewController.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>"; }; 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>"; }; D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIAlertController+Visibility.swift"; sourceTree = "<group>"; };
@ -700,6 +702,8 @@
D6D79F2A2A0D5D5C00AB2315 /* StatusEditCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditCollectionViewCell.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; D6D9498E298EB79400C59229 /* CopyableLable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableLable.swift; sourceTree = "<group>"; };
@ -1349,7 +1353,7 @@
D6BED1722126661300F02DA0 /* Views */ = { D6BED1722126661300F02DA0 /* Views */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */, D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameView.swift */,
D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */, D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */,
D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */, D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */,
D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */, D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */,
@ -1371,6 +1375,7 @@
D65B4B69297777D900DABDFB /* StatusNotFoundView.swift */, D65B4B69297777D900DABDFB /* StatusNotFoundView.swift */,
04ED00B021481ED800567C53 /* SteppedProgressView.swift */, 04ED00B021481ED800567C53 /* SteppedProgressView.swift */,
D6093FB625BE0CF3004811E6 /* TrendHistoryView.swift */, D6093FB625BE0CF3004811E6 /* TrendHistoryView.swift */,
D6D79F522A0FFE3200AB2315 /* ToggleableButton.swift */,
D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */, D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */,
D627944623A6AC9300D38C68 /* BasicTableViewCell.xib */, D627944623A6AC9300D38C68 /* BasicTableViewCell.xib */,
D6A3BC872321F78000FD64D5 /* Account Cell */, D6A3BC872321F78000FD64D5 /* Account Cell */,
@ -1383,6 +1388,7 @@
D641C78B213DD92F004B4513 /* Profile Header */, D641C78B213DD92F004B4513 /* Profile Header */,
D641C78A213DD926004B4513 /* Status */, D641C78A213DD926004B4513 /* Status */,
D64AAE8F26C80DB600FC57FB /* Toast */, D64AAE8F26C80DB600FC57FB /* Toast */,
D6D79F562A1160B800AB2315 /* AccountDisplayNameLabel.swift */,
); );
path = Views; path = Views;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1945,6 +1951,7 @@
D6BC9DB1232C61BC002CA326 /* NotificationsPageViewController.swift in Sources */, D6BC9DB1232C61BC002CA326 /* NotificationsPageViewController.swift in Sources */,
D6ADB6EC28EA73CB009924AB /* StatusContentContainer.swift in Sources */, D6ADB6EC28EA73CB009924AB /* StatusContentContainer.swift in Sources */,
D6969E9E240C81B9002843CE /* NSTextAttachment+Emoji.swift in Sources */, D6969E9E240C81B9002843CE /* NSTextAttachment+Emoji.swift in Sources */,
D6D79F532A0FFE3200AB2315 /* ToggleableButton.swift in Sources */,
D6BC9DB3232D4C07002CA326 /* WellnessPrefsView.swift in Sources */, D6BC9DB3232D4C07002CA326 /* WellnessPrefsView.swift in Sources */,
D6ADB6F028ED1F25009924AB /* CachedImageView.swift in Sources */, D6ADB6F028ED1F25009924AB /* CachedImageView.swift in Sources */,
D693DE5923FE24310061E07D /* InteractivePushTransition.swift in Sources */, D693DE5923FE24310061E07D /* InteractivePushTransition.swift in Sources */,
@ -2009,7 +2016,7 @@
D646C956213B365700269FB5 /* LargeImageExpandAnimationController.swift in Sources */, D646C956213B365700269FB5 /* LargeImageExpandAnimationController.swift in Sources */,
D646DCD22A06F2510059ECEB /* NotificationsCollectionViewController.swift in Sources */, D646DCD22A06F2510059ECEB /* NotificationsCollectionViewController.swift in Sources */,
D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */, D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */,
D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */, D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameView.swift in Sources */,
D63D8DF42850FE7A008D95E1 /* ViewTags.swift in Sources */, D63D8DF42850FE7A008D95E1 /* ViewTags.swift in Sources */,
D61DC84B28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift in Sources */, D61DC84B28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift in Sources */,
D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */, D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */,
@ -2105,6 +2112,7 @@
D663626C21361C6700C9CBA2 /* Account+Preferences.swift in Sources */, D663626C21361C6700C9CBA2 /* Account+Preferences.swift in Sources */,
D67895C0246870DE00D4CD9E /* LocalAccountAvatarView.swift in Sources */, D67895C0246870DE00D4CD9E /* LocalAccountAvatarView.swift in Sources */,
D6ADB6E828E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift in Sources */, D6ADB6E828E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift in Sources */,
D6D79F572A1160B800AB2315 /* AccountDisplayNameLabel.swift in Sources */,
D646DCAC2A06C8840059ECEB /* ProfileFieldValueView.swift in Sources */, D646DCAC2A06C8840059ECEB /* ProfileFieldValueView.swift in Sources */,
D6F4D79429ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift in Sources */, D6F4D79429ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift in Sources */,
D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */, D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */,
@ -2370,7 +2378,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements; CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 89; CURRENT_PROJECT_VERSION = 91;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2436,7 +2444,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements; CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 89; CURRENT_PROJECT_VERSION = 91;
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2462,7 +2470,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 89; CURRENT_PROJECT_VERSION = 91;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
@ -2491,7 +2499,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 89; CURRENT_PROJECT_VERSION = 91;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
@ -2520,7 +2528,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 89; CURRENT_PROJECT_VERSION = 91;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
@ -2675,7 +2683,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements; CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 89; CURRENT_PROJECT_VERSION = 91;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2706,7 +2714,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements; CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 89; CURRENT_PROJECT_VERSION = 91;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2812,7 +2820,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements; CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 89; CURRENT_PROJECT_VERSION = 91;
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2838,7 +2846,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements; CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 89; CURRENT_PROJECT_VERSION = 91;
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;

View File

@ -0,0 +1,105 @@
<?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,6 +88,10 @@
argument = "-com.apple.CoreData.ConcurrencyDebug 1" argument = "-com.apple.CoreData.ConcurrencyDebug 1"
isEnabled = "YES"> isEnabled = "YES">
</CommandLineArgument> </CommandLineArgument>
<CommandLineArgument
argument = "-com.apple.CoreData.CloudKitDebug 0"
isEnabled = "YES">
</CommandLineArgument>
<CommandLineArgument <CommandLineArgument
argument = "-UIFocusLoggingEnabled YES" argument = "-UIFocusLoggingEnabled YES"
isEnabled = "NO"> isEnabled = "NO">

View File

@ -37,16 +37,20 @@ class ImageCache {
completion?(entry.data, entry.image) completion?(entry.data, entry.image)
return nil return nil
} else { } else {
return Task.detached(priority: .userInitiated) { return getFromSource(url, completion: completion)
let result = await self.fetch(url: url) }
switch result { }
case .data(let data):
completion?(data, nil) func getFromSource(_ url: URL, completion: ((Data?, UIImage?) -> Void)?) -> Request? {
case .dataAndImage(let data, let image): return Task.detached(priority: .userInitiated) {
completion?(data, image) let result = await self.fetch(url: url)
case .none: switch result {
completion?(nil, nil) case .data(let data):
} completion?(data, nil)
case .dataAndImage(let data, let image):
completion?(data, image)
case .none:
completion?(nil, nil)
} }
} }
} }

View File

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

View File

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

View File

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

View File

@ -9,7 +9,7 @@
import UIKit import UIKit
struct ImageGrayscalifier { struct ImageGrayscalifier {
static let queue = DispatchQueue(label: "ImageGrayscalifier", qos: .default) static let queue = DispatchQueue(label: "ImageGrayscalifier", qos: .userInitiated)
private static let context = CIContext() private static let context = CIContext()
private static let cache = NSCache<NSURL, UIImage>() private static let cache = NSCache<NSURL, UIImage>()
@ -24,6 +24,17 @@ 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? { static func convert(url: URL?, data: Data) -> UIImage? {
if let url = url, if let url = url,
let cached = cache.object(forKey: url as NSURL) { 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 }, fetchAvatar: { @MainActor in await ImageCache.avatars.get($0).1 },
fetchAttachment: { @MainActor in await ImageCache.attachments.get($0).1 }, fetchAttachment: { @MainActor in await ImageCache.attachments.get($0).1 },
fetchStatus: { mastodonController.persistentContainer.status(for: $0) }, fetchStatus: { mastodonController.persistentContainer.status(for: $0) },
displayNameLabel: { AnyView(AccountDisplayNameLabel(account: $0, textStyle: $1, emojiSize: $2)) }, displayNameLabel: { AnyView(AccountDisplayNameView(account: $0, textStyle: $1, emojiSize: $2)) },
replyContentView: { AnyView(ComposeReplyContentView(status: $0, mastodonController: mastodonController, heightChanged: $1)) }, replyContentView: { AnyView(ComposeReplyContentView(status: $0, mastodonController: mastodonController, heightChanged: $1)) },
emojiImageView: { AnyView(CustomEmojiImageView(emoji: $0)) } 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) Item.status(id: node.status.id, node: node, state: .unknown, prevLink: index > 0, nextLink: true)
} }
snapshot.appendItems(parentItems, toSection: .ancestors) snapshot.appendItems(parentItems, toSection: .ancestors)
snapshot.reloadItems([mainStatusItem]) snapshot.reconfigureItems([mainStatusItem])
// convert sub-threads into items for section and add to snapshot // convert sub-threads into items for section and add to snapshot
self.addFlattenedChildThreadsToSnapshot(tree.descendants, mainStatus: mainStatus, snapshot: &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 headerImageView: UIImageView!
@IBOutlet weak var avatarContainerView: UIView! @IBOutlet weak var avatarContainerView: UIView!
@IBOutlet weak var avatarImageView: UIImageView! @IBOutlet weak var avatarImageView: UIImageView!
@IBOutlet weak var displayNameLabel: EmojiLabel! @IBOutlet weak var displayNameLabel: AccountDisplayNameLabel!
@IBOutlet weak var noteTextView: StatusContentTextView! @IBOutlet weak var noteTextView: StatusContentTextView!
var account: Account? var account: Account?

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<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"> <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">
<device id="retina6_1" orientation="portrait" appearance="light"/> <device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies> <dependencies>
<deployment identifier="iOS"/> <deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21505"/> <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21678"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/> <capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/> <capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.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"/> <constraint firstItem="4wd-wq-Sh2" firstAttribute="centerX" secondItem="RQe-uE-TEv" secondAttribute="centerX" id="bRk-uJ-JGg"/>
</constraints> </constraints>
</view> </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="EmojiLabel" 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="AccountDisplayNameLabel" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="76" y="72" width="316" height="24"/> <rect key="frame" x="76" y="72" width="316" height="24"/>
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="20"/> <fontDescription key="fontDescription" type="system" weight="semibold" pointSize="20"/>
<nil key="textColor"/> <nil key="textColor"/>

View File

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

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<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"> <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">
<device id="retina6_12" orientation="portrait" appearance="light"/> <device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies> <dependencies>
<deployment identifier="iOS"/> <deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21505"/> <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21678"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/> <capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="collection view cell content view" 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"/> <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"/> <constraint firstAttribute="width" constant="90" id="wav-YT-e4Y"/>
</constraints> </constraints>
</view> </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="EmojiLabel" 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="AccountDisplayNameLabel" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="106" y="100" width="333" height="29"/> <rect key="frame" x="106" y="100" width="333" height="29"/>
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="24"/> <fontDescription key="fontDescription" type="system" weight="semibold" pointSize="24"/>
<nil key="textColor"/> <nil key="textColor"/>

View File

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

View File

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

View File

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

View File

@ -9,7 +9,9 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
class StatusEditPollView: UIStackView { class StatusEditPollView: UIStackView, StatusContentPollView {
private var titleLabels: [EmojiLabel] = []
init() { init() {
super.init(frame: .zero) super.init(frame: .zero)
@ -25,6 +27,7 @@ class StatusEditPollView: UIStackView {
func updateUI(poll: StatusEdit.Poll?, emojis: [Emoji]) { func updateUI(poll: StatusEdit.Poll?, emojis: [Emoji]) {
arrangedSubviews.forEach { $0.removeFromSuperview() } arrangedSubviews.forEach { $0.removeFromSuperview() }
titleLabels = []
for option in poll?.options ?? [] { for option in poll?.options ?? [] {
// the edit poll doesn't actually include the multiple value // the edit poll doesn't actually include the multiple value
@ -33,6 +36,7 @@ class StatusEditPollView: UIStackView {
let label = EmojiLabel() let label = EmojiLabel()
label.text = option.title label.text = option.title
label.setEmojis(emojis, identifier: Optional<String>.none) label.setEmojis(emojis, identifier: Optional<String>.none)
titleLabels.append(label)
let stack = UIStackView(arrangedSubviews: [ let stack = UIStackView(arrangedSubviews: [
icon, icon,
label, label,
@ -43,5 +47,15 @@ class StatusEditPollView: UIStackView {
addArrangedSubview(stack) addArrangedSubview(stack)
} }
} }
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 } var mastodonController: MastodonController! { delegate?.apiController }
@IBOutlet weak var avatarImageView: UIImageView! @IBOutlet weak var avatarImageView: UIImageView!
@IBOutlet weak var displayNameLabel: EmojiLabel! @IBOutlet weak var displayNameLabel: AccountDisplayNameLabel!
@IBOutlet weak var usernameLabel: UILabel! @IBOutlet weak var usernameLabel: UILabel!
@IBOutlet weak var noteLabel: EmojiLabel! @IBOutlet weak var noteLabel: EmojiLabel!

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<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"> <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">
<device id="retina6_1" orientation="portrait" appearance="light"/> <device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies> <dependencies>
<deployment identifier="iOS"/> <deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21505"/> <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21678"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/> <capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/> <capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.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"> <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="Iif-9m-vM5">
<rect key="frame" x="74" y="11" width="230" height="78"/> <rect key="frame" x="74" y="11" width="230" height="78"/>
<subviews> <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="EmojiLabel" 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="AccountDisplayNameLabel" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="230" height="20.5"/> <rect key="frame" x="0.0" y="0.0" width="230" height="20.5"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/> <fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/> <nil key="textColor"/>

View File

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

View File

@ -2,106 +2,31 @@
// AccountDisplayNameLabel.swift // AccountDisplayNameLabel.swift
// Tusker // Tusker
// //
// Created by Shadowfacts on 9/7/20. // Created by Shadowfacts on 5/14/23.
// Copyright © 2020 Shadowfacts. All rights reserved. // Copyright © 2023 Shadowfacts. All rights reserved.
// //
import SwiftUI import UIKit
import Pachyderm import Pachyderm
import WebURLFoundationExtras
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: []) class AccountDisplayNameLabel: EmojiLabel {
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 { func updateForAccountDisplayName(account: some AccountProtocol) {
let account: any AccountProtocol guard accountID != account.id || accountDisplayName != account.displayName || Preferences.shared.hideCustomEmojiInUsernames == hasEmojis else {
let textStyle: Font.TextStyle return
@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)
}
} }
accountID = account.id
group.notify(queue: .main) { accountDisplayName = account.displayName
var text: Text? self.text = accountDisplayName
if Preferences.shared.hideCustomEmojiInUsernames {
var endIndex = account.displayName.utf16.count self.removeEmojis()
} else {
// iterate backwards as to not alter the indices of earlier matches self.setEmojis(account.emojis, identifier: account.id)
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 {
// AccountDisplayNameLabel()
// }
//}

View File

@ -0,0 +1,107 @@
//
// 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,9 +75,7 @@ class AttachmentView: GIFImageView {
gifPlaybackModeChanged() gifPlaybackModeChanged()
if isGrayscale != Preferences.shared.grayscaleImages { if isGrayscale != Preferences.shared.grayscaleImages {
ImageGrayscalifier.queue.async { self.displayImage()
self.displayImage()
}
} }
if getBadges().isEmpty != Preferences.shared.showAttachmentBadges { if getBadges().isEmpty != Preferences.shared.showAttachmentBadges {
@ -189,51 +187,57 @@ class AttachmentView: GIFImageView {
func loadImage() { func loadImage() {
let attachmentURL = attachment.url let attachmentURL = attachment.url
attachmentRequest = ImageCache.attachments.get(attachmentURL) { [weak self] (data, _) in attachmentRequest = ImageCache.attachments.get(attachmentURL) { [weak self] (data, image) in
guard let self = self, let data = data else { return } guard let self = self,
self.attachment.url == attachmentURL else {
return
}
DispatchQueue.main.async { DispatchQueue.main.async {
self.attachmentRequest = nil self.attachmentRequest = nil
}
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 { if attachmentURL.pathExtension == "gif",
let data {
self.source = .gifData(attachmentURL, data, image)
if self.autoplayGifs {
let controller = GIFController(gifData: data)
controller.attach(to: self)
controller.startAnimating()
} else {
self.displayImage()
}
} else if let image {
self.source = .image(attachmentURL, image)
self.displayImage() self.displayImage()
} }
} else {
self.source = .imageData(attachmentURL, data)
self.displayImage()
} }
} }
} }
func loadVideo() { func loadVideo() {
if let previewURL = self.attachment.previewURL { if let previewURL = self.attachment.previewURL {
attachmentRequest = ImageCache.attachments.get(previewURL, completion: { [weak self] (data, _ )in attachmentRequest = ImageCache.attachments.get(previewURL, completion: { [weak self] (_, image) in
guard let self = self, let data = data else { return } guard let self, let image else { return }
DispatchQueue.main.async { DispatchQueue.main.async {
self.attachmentRequest = nil self.attachmentRequest = nil
self.source = .imageData(previewURL, data) self.source = .image(previewURL, image)
self.displayImage() self.displayImage()
} }
}) })
} else { } else {
let attachmentURL = self.attachment.url let attachmentURL = self.attachment.url
AttachmentView.queue.async { AttachmentView.queue.async { [weak self] in
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 }
self.source = .cgImage(attachmentURL, image) UIImage(cgImage: image).prepareForDisplay { [weak self] image in
self.displayImage() DispatchQueue.main.async { [weak self] in
guard let self, let image else { return }
self.source = .image(attachmentURL, image)
self.displayImage()
}
}
} }
} }
@ -276,8 +280,10 @@ class AttachmentView: GIFImageView {
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 }
self.source = .cgImage(attachmentURL, image) DispatchQueue.main.async {
self.displayImage() self.source = .cgImage(attachmentURL, image)
self.displayImage()
}
} }
let gifvView = GifvAttachmentView(asset: asset, gravity: .resizeAspectFill) let gifvView = GifvAttachmentView(asset: asset, gravity: .resizeAspectFill)
@ -295,33 +301,51 @@ class AttachmentView: GIFImageView {
]) ])
} }
@MainActor
private func displayImage() { private func displayImage() {
isGrayscale = Preferences.shared.grayscaleImages isGrayscale = Preferences.shared.grayscaleImages
let image: UIImage?
switch source { switch source {
case nil: case nil:
image = nil self.image = nil
case let .imageData(url, data), let .gifData(url, data): case let .image(url, sourceImage):
if isGrayscale { if isGrayscale {
image = ImageGrayscalifier.convert(url: url, data: data) ImageGrayscalifier.queue.async { [weak self] in
let grayscale = ImageGrayscalifier.convert(url: url, image: sourceImage)
DispatchQueue.main.async { [weak self] in
self?.image = grayscale
}
}
} else { } else {
image = UIImage(data: data) 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
} }
case let .cgImage(url, cgImage): case let .cgImage(url, cgImage):
if isGrayscale { if isGrayscale {
image = ImageGrayscalifier.convert(url: url, cgImage: cgImage) ImageGrayscalifier.queue.async { [weak self] in
let grayscale = ImageGrayscalifier.convert(url: url, cgImage: cgImage)
DispatchQueue.main.async { [weak self] in
self?.image = grayscale
}
}
} else { } else {
image = UIImage(cgImage: cgImage) image = UIImage(cgImage: cgImage)
} }
} }
DispatchQueue.main.async {
self.image = image
}
} }
private func createBadgesView(_ badges: Badges) { private func createBadgesView(_ badges: Badges) {
@ -409,8 +433,8 @@ class AttachmentView: GIFImageView {
fileprivate extension AttachmentView { fileprivate extension AttachmentView {
enum Source { enum Source {
case imageData(URL, Data) case image(URL, UIImage)
case gifData(URL, Data) case gifData(URL, Data, UIImage?)
case cgImage(URL, CGImage) case cgImage(URL, CGImage)
} }

View File

@ -22,6 +22,15 @@ class AttachmentsContainerView: UIView {
let attachmentStacks: NSHashTable<UIStackView> = .weakObjects() let attachmentStacks: NSHashTable<UIStackView> = .weakObjects()
var moreView: UIView? var moreView: UIView?
private var aspectRatioConstraint: NSLayoutConstraint? 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 blurView: UIVisualEffectView?
var hideButtonView: UIVisualEffectView? var hideButtonView: UIVisualEffectView?
@ -93,7 +102,8 @@ class AttachmentsContainerView: UIView {
fillView(attachmentView) fillView(attachmentView)
sendSubviewToBack(attachmentView) sendSubviewToBack(attachmentView)
accessibilityElements.append(attachmentView) accessibilityElements.append(attachmentView)
if let attachmentAspectRatio = attachmentView.attachmentAspectRatio { if Preferences.shared.showUncroppedMediaInline,
let attachmentAspectRatio = attachmentView.attachmentAspectRatio {
aspectRatio = attachmentAspectRatio aspectRatio = attachmentAspectRatio
} }
case 2: case 2:
@ -266,18 +276,7 @@ class AttachmentsContainerView: UIView {
accessibilityElements.append(moreView) accessibilityElements.append(moreView)
} }
if Preferences.shared.showUncroppedMediaInline { self.aspectRatio = aspectRatio
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 { } else {
self.isHidden = true self.isHidden = true
} }

View File

@ -40,6 +40,16 @@ extension BaseEmojiLabel {
return 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>() let emojiImages = MultiThreadDictionary<String, UIImage>()
var foundEmojis = false var foundEmojis = false
@ -57,21 +67,35 @@ extension BaseEmojiLabel {
foundEmojis = true foundEmojis = true
if let image = ImageCache.emojis.get(URL(emoji.url)!)?.image { if let image = ImageCache.emojis.get(URL(emoji.url)!)?.image {
// if the image is cached, add it immediately // if the image is cached, add it immediately.
emojiImages[emoji.shortcode] = image // 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)
}
} else { } else {
// otherwise, perform the network request // otherwise, perform the network request
group.enter() group.enter()
// 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.getFromSource(URL(emoji.url)!) { (_, image) in
let request = ImageCache.emojis.get(URL(emoji.url)!) { (_, image) in guard let image else {
guard let image = image, group.leave()
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: URL(emoji.url)!, image: image) else { return
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 {
group.leave()
return
}
emojiImages[emoji.shortcode] = transformedImage
group.leave()
} }
emojiImages[emoji.shortcode] = transformedImage
group.leave()
} }
if let request = request { if let request = request {
emojiRequests.append(request) emojiRequests.append(request)
@ -91,10 +115,8 @@ 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) // 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 // so, just ignore the warnings
let emojiAttachments = emojiImages.withLock { let emojiAttachments = emojiImages.withLock {
let emojiFont = self.emojiFont
let emojiTextColor = self.emojiTextColor
return $0.mapValues { image in return $0.mapValues { image in
NSTextAttachment(emojiImage: image, in: emojiFont, with: emojiTextColor) NSTextAttachment(image: image)
} }
} }
let placeholder = usePlaceholders ? NSTextAttachment(emojiPlaceholderIn: self.emojiFont) : nil let placeholder = usePlaceholders ? NSTextAttachment(emojiPlaceholderIn: self.emojiFont) : nil

View File

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

View File

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

View File

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

View File

@ -14,7 +14,7 @@ class PollOptionsView: UIControl {
var mastodonController: MastodonController! var mastodonController: MastodonController!
var checkedOptionIndices: [Int] { var checkedOptionIndices: [Int] {
options.enumerated().filter { $0.element.checkbox.isChecked }.map(\.offset) options.enumerated().filter { $0.element.checkbox?.isChecked == true }.map(\.offset)
} }
var checkedOptionsChanged: (() -> Void)? var checkedOptionsChanged: (() -> Void)?
@ -32,7 +32,7 @@ class PollOptionsView: UIControl {
override var isEnabled: Bool { override var isEnabled: Bool {
didSet { didSet {
options.forEach { $0.checkbox.readOnly = !isEnabled } options.forEach { $0.checkbox?.readOnly = !isEnabled }
} }
} }
@ -65,9 +65,11 @@ class PollOptionsView: UIControl {
options = poll.options.enumerated().map { (index, opt) in options = poll.options.enumerated().map { (index, opt) in
let optionView = PollOptionView(poll: poll, option: opt, mastodonController: mastodonController) let optionView = PollOptionView(poll: poll, option: opt, mastodonController: mastodonController)
optionView.checkbox.readOnly = !isEnabled if let checkbox = optionView.checkbox {
optionView.checkbox.isChecked = poll.ownVotes?.contains(index) ?? false checkbox.readOnly = !isEnabled
optionView.checkbox.voted = poll.voted ?? false checkbox.isChecked = poll.ownVotes?.contains(index) ?? false
checkbox.voted = poll.voted ?? false
}
stack.addArrangedSubview(optionView) stack.addArrangedSubview(optionView)
return optionView return optionView
} }
@ -75,15 +77,25 @@ class PollOptionsView: UIControl {
accessibilityElements = options 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) { private func selectOption(_ option: PollOptionView) {
if poll.multiple { if poll.multiple {
option.checkbox.isChecked.toggle() option.checkbox?.isChecked.toggle()
} else { } else {
for opt in options { for opt in options {
if opt === option { if opt === option {
opt.checkbox.isChecked = true opt.checkbox?.isChecked = true
} else { } else {
opt.checkbox.isChecked = false opt.checkbox?.isChecked = false
} }
} }
} }

View File

@ -9,7 +9,7 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
class StatusPollView: UIView { class StatusPollView: UIView, StatusContentPollView {
private static let formatter: DateComponentsFormatter = { private static let formatter: DateComponentsFormatter = {
let f = DateComponentsFormatter() let f = DateComponentsFormatter()
@ -140,6 +140,11 @@ class StatusPollView: UIView {
voteButton.isEnabled = false 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() { private func checkedOptionsChanged() {
voteButton.isEnabled = optionsView.checkedOptionIndices.count > 0 voteButton.isEnabled = optionsView.checkedOptionIndices.count > 0
} }

View File

@ -28,7 +28,7 @@ class ProfileHeaderView: UIView {
@IBOutlet weak var avatarImageView: UIImageView! @IBOutlet weak var avatarImageView: UIImageView!
@IBOutlet weak var moreButton: ProfileHeaderButton! @IBOutlet weak var moreButton: ProfileHeaderButton!
@IBOutlet weak var followButton: ProfileHeaderButton! @IBOutlet weak var followButton: ProfileHeaderButton!
@IBOutlet weak var displayNameLabel: EmojiLabel! @IBOutlet weak var displayNameLabel: AccountDisplayNameLabel!
@IBOutlet weak var usernameLabel: UILabel! @IBOutlet weak var usernameLabel: UILabel!
@IBOutlet weak var lockImageView: UIImageView! @IBOutlet weak var lockImageView: UIImageView!
@IBOutlet weak var vStack: UIStackView! @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"/> <constraint firstItem="TkY-oK-if4" firstAttribute="centerX" secondItem="wT9-2J-uSY" secondAttribute="centerX" id="ozz-sa-gSc"/>
</constraints> </constraints>
</view> </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="EmojiLabel" 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="AccountDisplayNameLabel" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="144" y="206" width="254" height="29"/> <rect key="frame" x="144" y="206" width="254" height="29"/>
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="24"/> <fontDescription key="fontDescription" type="system" weight="semibold" pointSize="24"/>
<nil key="textColor"/> <nil key="textColor"/>

View File

@ -39,7 +39,7 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
$0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(accountPressed))) $0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(accountPressed)))
} }
let displayNameLabel = EmojiLabel().configure { let displayNameLabel = AccountDisplayNameLabel().configure {
$0.font = UIFontMetrics(forTextStyle: .title1).scaledFont(for: .systemFont(ofSize: 24, weight: .semibold)) $0.font = UIFontMetrics(forTextStyle: .title1).scaledFont(for: .systemFont(ofSize: 24, weight: .semibold))
$0.adjustsFontForContentSizeCategory = true $0.adjustsFontForContentSizeCategory = true
} }
@ -191,13 +191,13 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
$0.addInteraction(UIPointerInteraction(delegate: self)) $0.addInteraction(UIPointerInteraction(delegate: self))
} }
private(set) lazy var favoriteButton = UIButton().configure { private(set) lazy var favoriteButton = ToggleableButton(activeColor: UIColor(displayP3Red: 1, green: 0.8, blue: 0, alpha: 1)).configure {
$0.setImage(UIImage(systemName: "star.fill"), for: .normal) $0.setImage(UIImage(systemName: "star.fill"), for: .normal)
$0.addTarget(self, action: #selector(favoritePressed), for: .touchUpInside) $0.addTarget(self, action: #selector(favoritePressed), for: .touchUpInside)
$0.addInteraction(UIPointerInteraction(delegate: self)) $0.addInteraction(UIPointerInteraction(delegate: self))
} }
private(set) lazy var reblogButton = UIButton().configure { private(set) lazy var reblogButton = ToggleableButton(activeColor: UIColor(displayP3Red: 1, green: 0.8, blue: 0, alpha: 1)).configure {
$0.setImage(UIImage(systemName: "repeat"), for: .normal) $0.setImage(UIImage(systemName: "repeat"), for: .normal)
$0.addTarget(self, action: #selector(reblogPressed), for: .touchUpInside) $0.addTarget(self, action: #selector(reblogPressed), for: .touchUpInside)
$0.addInteraction(UIPointerInteraction(delegate: self)) $0.addInteraction(UIPointerInteraction(delegate: self))
@ -348,24 +348,6 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
accountDetailAccessibilityElement.navigationDelegate = delegate accountDetailAccessibilityElement.navigationDelegate = delegate
accountDetailAccessibilityElement.accountID = accountID 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) var timestampAndClientText = ConversationMainStatusCollectionViewCell.dateFormatter.string(from: status.createdAt)
if let application = status.applicationName { if let application = status.applicationName {
timestampAndClientText += "\(application)" timestampAndClientText += "\(application)"
@ -376,7 +358,9 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
editTimestampButton.isHidden = false editTimestampButton.isHidden = false
var config = UIButton.Configuration.plain() var config = UIButton.Configuration.plain()
config.baseForegroundColor = .secondaryLabel config.baseForegroundColor = .secondaryLabel
config.attributedTitle = AttributedString("Edited on \(ConversationMainStatusCollectionViewCell.dateFormatter.string(from: editedAt))", attributes: metaButtonAttributes) config.attributedTitle = AttributedString("Edited on \(ConversationMainStatusCollectionViewCell.dateFormatter.string(from: editedAt))", attributes: AttributeContainer([
.font: ConversationMainStatusCollectionViewCell.metaFont
]))
config.contentInsets = .zero config.contentInsets = .zero
editTimestampButton.configuration = config editTimestampButton.configuration = config
} else { } else {
@ -392,6 +376,33 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
baseCreateObservers() 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) { func updateUIForPreferences(status: StatusMO) {
baseUpdateUIForPreferences(status: status) baseUpdateUIForPreferences(status: status)
} }

View File

@ -199,38 +199,6 @@ 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?) { override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
hStack.backgroundColor = activeBackgroundColor hStack.backgroundColor = activeBackgroundColor
setNeedsDisplay() setNeedsDisplay()

View File

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

View File

@ -8,7 +8,11 @@
import UIKit import UIKit
class StatusContentContainer<ContentView: ContentTextView, PollView: UIView>: UIView { protocol StatusContentPollView: UIView {
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat
}
class StatusContentContainer<ContentView: ContentTextView, PollView: StatusContentPollView>: UIView {
private var useTopSpacer = false private var useTopSpacer = false
private let topSpacer = UIView().configure { private let topSpacer = UIView().configure {
@ -25,9 +29,10 @@ class StatusContentContainer<ContentView: ContentTextView, PollView: UIView>: UI
$0.isSelectable = false $0.isSelectable = false
} }
private static var cardViewHeight: CGFloat { 90 }
let cardView = StatusCardView().configure { let cardView = StatusCardView().configure {
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
$0.heightAnchor.constraint(equalToConstant: 90), $0.heightAnchor.constraint(equalToConstant: StatusContentContainer.cardViewHeight),
]) ])
} }
@ -125,11 +130,30 @@ class StatusContentContainer<ContentView: ContentTextView, PollView: UIView>: UI
} }
isCollapsed = collapsed isCollapsed = collapsed
// ensure that we have a lastSubviewBottomConstraint // don't call setNeedsUpdateConstraints b/c that destroys/recreates a bunch of other constraints
updateConstraintsIfNeeded() // if there is no lastSubviewBottomConstraint, then we already need a constraint update, so we don't need to do anything here
// force unwrap because the content container should always have at least one view if let lastSubviewBottomConstraint {
lastSubviewBottomConstraint!.isActive = !collapsed lastSubviewBottomConstraint.isActive = !collapsed
zeroHeightConstraint.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 $0.spacing = 4
} }
let displayNameLabel = EmojiLabel().configure { let displayNameLabel = AccountDisplayNameLabel().configure {
$0.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([ $0.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([
.traits: [ .traits: [
UIFontDescriptor.TraitKey.weight: UIFont.Weight.semibold.rawValue, UIFontDescriptor.TraitKey.weight: UIFont.Weight.semibold.rawValue,
@ -238,13 +238,13 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
$0.addInteraction(UIPointerInteraction(delegate: self)) $0.addInteraction(UIPointerInteraction(delegate: self))
} }
private(set) lazy var favoriteButton = UIButton().configure { private(set) lazy var favoriteButton = ToggleableButton(activeColor: UIColor(displayP3Red: 1, green: 0.8, blue: 0, alpha: 1)).configure {
$0.setImage(UIImage(systemName: "star.fill"), for: .normal) $0.setImage(UIImage(systemName: "star.fill"), for: .normal)
$0.addTarget(self, action: #selector(favoritePressed), for: .touchUpInside) $0.addTarget(self, action: #selector(favoritePressed), for: .touchUpInside)
$0.addInteraction(UIPointerInteraction(delegate: self)) $0.addInteraction(UIPointerInteraction(delegate: self))
} }
private(set) lazy var reblogButton = UIButton().configure { private(set) lazy var reblogButton = ToggleableButton(activeColor: UIColor(displayP3Red: 1, green: 0.8, blue: 0, alpha: 1)).configure {
$0.setImage(UIImage(systemName: "repeat"), for: .normal) $0.setImage(UIImage(systemName: "repeat"), for: .normal)
$0.addTarget(self, action: #selector(reblogPressed), for: .touchUpInside) $0.addTarget(self, action: #selector(reblogPressed), for: .touchUpInside)
$0.addInteraction(UIPointerInteraction(delegate: self)) $0.addInteraction(UIPointerInteraction(delegate: self))
@ -619,10 +619,15 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
metaIndicatorsView.updateUI(status: status) metaIndicatorsView.updateUI(status: status)
timelineReasonIcon.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * Self.timelineReasonIconSize 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() { private func updateTimestamp() {
@ -694,6 +699,11 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
updateUIForPreferences(status: status) updateUIForPreferences(status: status)
if let rebloggerID,
let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {
updateRebloggerLabel(reblogger: reblogger)
}
if isGrayscale != Preferences.shared.grayscaleImages { if isGrayscale != Preferences.shared.grayscaleImages {
updateGrayscaleableUI(status: status) updateGrayscaleableUI(status: status)
} }

View File

@ -0,0 +1,31 @@
//
// 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")
}
}