Compare commits

...

6 Commits

14 changed files with 190 additions and 61 deletions

View File

@ -22,8 +22,7 @@ extension NSTextAttachment {
image.draw(in: CGRect(origin: .zero, size: imageSizeMatchingFontSize)) image.draw(in: CGRect(origin: .zero, size: imageSizeMatchingFontSize))
} }
self.init() self.init(image: attachmentImage)
self.image = attachmentImage
} }
convenience init(emojiPlaceholderIn font: UIFont) { convenience init(emojiPlaceholderIn font: UIFont) {
@ -31,7 +30,6 @@ extension NSTextAttachment {
// assumes emoji are mostly square // assumes emoji are mostly square
let size = CGSize(width: adjustedCapHeight, height: adjustedCapHeight) let size = CGSize(width: adjustedCapHeight, height: adjustedCapHeight)
let image = UIGraphicsImageRenderer(size: size).image { (_) in } let image = UIGraphicsImageRenderer(size: size).image { (_) in }
self.init() self.init(image: image)
self.image = image
} }
} }

View File

@ -11,11 +11,10 @@ import Pachyderm
extension StatusState { extension StatusState {
func resolveFor(status: StatusMO, text: String?) { func resolveFor(status: StatusMO, height: CGFloat) {
let longEnoughToCollapse: Bool let longEnoughToCollapse: Bool
if Preferences.shared.collapseLongPosts, if Preferences.shared.collapseLongPosts,
let text = text, height > 500 {
text.count > 500 {
longEnoughToCollapse = true longEnoughToCollapse = true
} else { } else {
longEnoughToCollapse = false longEnoughToCollapse = false

View File

@ -64,3 +64,18 @@ extension Status.Visibility {
} }
} }
extension Status.Visibility: Comparable {
public static func < (lhs: Pachyderm.Status.Visibility, rhs: Pachyderm.Status.Visibility) -> Bool {
switch (lhs, rhs) {
case (.direct, .public), (.private, .public), (.unlisted, .public):
return true
case (.direct, .unlisted), (.private, .unlisted):
return true
case (.direct, .private):
return true
default:
return false
}
}
}

View File

@ -187,14 +187,14 @@ extension MastodonController {
func createDraft(inReplyToID: String? = nil, mentioningAcct: String? = nil) -> Draft { func createDraft(inReplyToID: String? = nil, mentioningAcct: String? = nil) -> Draft {
var acctsToMention = [String]() var acctsToMention = [String]()
var visibility = Preferences.shared.defaultPostVisibility var visibility = inReplyToID != nil ? Preferences.shared.defaultReplyVisibility.resolved : Preferences.shared.defaultPostVisibility
var contentWarning = "" var contentWarning = ""
if let inReplyToID = inReplyToID, if let inReplyToID = inReplyToID,
let inReplyTo = persistentContainer.status(for: inReplyToID) { let inReplyTo = persistentContainer.status(for: inReplyToID) {
acctsToMention.append(inReplyTo.account.acct) acctsToMention.append(inReplyTo.account.acct)
acctsToMention.append(contentsOf: inReplyTo.mentions.map(\.acct)) acctsToMention.append(contentsOf: inReplyTo.mentions.map(\.acct))
visibility = inReplyTo.visibility visibility = min(visibility, inReplyTo.visibility)
if !inReplyTo.spoilerText.isEmpty { if !inReplyTo.spoilerText.isEmpty {
switch Preferences.shared.contentWarningCopyMode { switch Preferences.shared.contentWarningCopyMode {

View File

@ -45,12 +45,14 @@ class Preferences: Codable, ObservableObject {
self.hideActionsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .hideActionsInTimeline) ?? false self.hideActionsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .hideActionsInTimeline) ?? false
self.defaultPostVisibility = try container.decode(Status.Visibility.self, forKey: .defaultPostVisibility) self.defaultPostVisibility = try container.decode(Status.Visibility.self, forKey: .defaultPostVisibility)
self.defaultReplyVisibility = try container.decodeIfPresent(ReplyVisibility.self, forKey: .defaultReplyVisibility) ?? .sameAsPost
self.automaticallySaveDrafts = try container.decode(Bool.self, forKey: .automaticallySaveDrafts) self.automaticallySaveDrafts = try container.decode(Bool.self, forKey: .automaticallySaveDrafts)
self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions) self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions)
self.contentWarningCopyMode = try container.decode(ContentWarningCopyMode.self, forKey: .contentWarningCopyMode) self.contentWarningCopyMode = try container.decode(ContentWarningCopyMode.self, forKey: .contentWarningCopyMode)
self.mentionReblogger = try container.decode(Bool.self, forKey: .mentionReblogger) self.mentionReblogger = try container.decode(Bool.self, forKey: .mentionReblogger)
self.blurAllMedia = try container.decode(Bool.self, forKey: .blurAllMedia) self.blurAllMedia = try container.decode(Bool.self, forKey: .blurAllMedia)
self.blurMediaBehindContentWarning = try container.decodeIfPresent(Bool.self, forKey: .blurMediaBehindContentWarning) ?? true
self.automaticallyPlayGifs = try container.decode(Bool.self, forKey: .automaticallyPlayGifs) self.automaticallyPlayGifs = try container.decode(Bool.self, forKey: .automaticallyPlayGifs)
self.openLinksInApps = try container.decode(Bool.self, forKey: .openLinksInApps) self.openLinksInApps = try container.decode(Bool.self, forKey: .openLinksInApps)
@ -84,12 +86,14 @@ class Preferences: Codable, ObservableObject {
try container.encode(hideActionsInTimeline, forKey: .hideActionsInTimeline) try container.encode(hideActionsInTimeline, forKey: .hideActionsInTimeline)
try container.encode(defaultPostVisibility, forKey: .defaultPostVisibility) try container.encode(defaultPostVisibility, forKey: .defaultPostVisibility)
try container.encode(defaultReplyVisibility, forKey: .defaultReplyVisibility)
try container.encode(automaticallySaveDrafts, forKey: .automaticallySaveDrafts) try container.encode(automaticallySaveDrafts, forKey: .automaticallySaveDrafts)
try container.encode(requireAttachmentDescriptions, forKey: .requireAttachmentDescriptions) try container.encode(requireAttachmentDescriptions, forKey: .requireAttachmentDescriptions)
try container.encode(contentWarningCopyMode, forKey: .contentWarningCopyMode) try container.encode(contentWarningCopyMode, forKey: .contentWarningCopyMode)
try container.encode(mentionReblogger, forKey: .mentionReblogger) try container.encode(mentionReblogger, forKey: .mentionReblogger)
try container.encode(blurAllMedia, forKey: .blurAllMedia) try container.encode(blurAllMedia, forKey: .blurAllMedia)
try container.encode(blurMediaBehindContentWarning, forKey: .blurMediaBehindContentWarning)
try container.encode(automaticallyPlayGifs, forKey: .automaticallyPlayGifs) try container.encode(automaticallyPlayGifs, forKey: .automaticallyPlayGifs)
try container.encode(openLinksInApps, forKey: .openLinksInApps) try container.encode(openLinksInApps, forKey: .openLinksInApps)
@ -122,13 +126,21 @@ class Preferences: Codable, ObservableObject {
// MARK: Composing // MARK: Composing
@Published var defaultPostVisibility = Status.Visibility.public @Published var defaultPostVisibility = Status.Visibility.public
@Published var defaultReplyVisibility = ReplyVisibility.sameAsPost
@Published var automaticallySaveDrafts = true @Published var automaticallySaveDrafts = true
@Published var requireAttachmentDescriptions = false @Published var requireAttachmentDescriptions = false
@Published var contentWarningCopyMode = ContentWarningCopyMode.asIs @Published var contentWarningCopyMode = ContentWarningCopyMode.asIs
@Published var mentionReblogger = false @Published var mentionReblogger = false
// MARK: Media // MARK: Media
@Published var blurAllMedia = false @Published var blurAllMedia = false {
didSet {
if blurAllMedia {
blurMediaBehindContentWarning = true
}
}
}
@Published var blurMediaBehindContentWarning = true
@Published var automaticallyPlayGifs = true @Published var automaticallyPlayGifs = true
// MARK: Behavior // MARK: Behavior
@ -164,12 +176,14 @@ class Preferences: Codable, ObservableObject {
case hideActionsInTimeline case hideActionsInTimeline
case defaultPostVisibility case defaultPostVisibility
case defaultReplyVisibility
case automaticallySaveDrafts case automaticallySaveDrafts
case requireAttachmentDescriptions case requireAttachmentDescriptions
case contentWarningCopyMode case contentWarningCopyMode
case mentionReblogger case mentionReblogger
case blurAllMedia case blurAllMedia
case blurMediaBehindContentWarning
case automaticallyPlayGifs case automaticallyPlayGifs
case openLinksInApps case openLinksInApps
@ -194,4 +208,40 @@ class Preferences: Codable, ObservableObject {
} }
extension Preferences {
enum ReplyVisibility: Codable, Hashable, CaseIterable {
case sameAsPost
case visibility(Status.Visibility)
static var allCases: [Preferences.ReplyVisibility] = [.sameAsPost] + Status.Visibility.allCases.map { .visibility($0) }
var resolved: Status.Visibility {
switch self {
case .sameAsPost:
return Preferences.shared.defaultPostVisibility
case .visibility(let vis):
return vis
}
}
var displayName: String {
switch self {
case .sameAsPost:
return "Same as Default"
case .visibility(let vis):
return vis.displayName
}
}
var imageName: String? {
switch self {
case .sameAsPost:
return nil
case .visibility(let vis):
return vis.imageName
}
}
}
}
extension UIUserInterfaceStyle: Codable {} extension UIUserInterfaceStyle: Codable {}

View File

@ -14,6 +14,30 @@ protocol ComposeDrawingViewControllerDelegate: AnyObject {
func composeDrawingViewController(_ drawingController: ComposeDrawingViewController, saveDrawing drawing: PKDrawing) func composeDrawingViewController(_ drawingController: ComposeDrawingViewController, saveDrawing drawing: PKDrawing)
} }
class ComposeDrawingNavigationController: UINavigationController {
override var preferredStatusBarStyle: UIStatusBarStyle {
.darkContent
}
init(editing initialDrawing: PKDrawing, delegate: ComposeDrawingViewControllerDelegate) {
let vc = ComposeDrawingViewController(editing: initialDrawing)
vc.delegate = delegate
super.init(rootViewController: vc)
modalPresentationStyle = .fullScreen
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
overrideUserInterfaceStyle = .light
}
}
class ComposeDrawingViewController: UIViewController { class ComposeDrawingViewController: UIViewController {
weak var delegate: ComposeDrawingViewControllerDelegate? weak var delegate: ComposeDrawingViewControllerDelegate?
@ -63,7 +87,8 @@ class ComposeDrawingViewController: UIViewController {
canvasView.drawingPolicy = .anyInput canvasView.drawingPolicy = .anyInput
canvasView.minimumZoomScale = 0.5 canvasView.minimumZoomScale = 0.5
canvasView.maximumZoomScale = 2 canvasView.maximumZoomScale = 2
canvasView.backgroundColor = .systemBackground canvasView.backgroundColor = .white
canvasView.overrideUserInterfaceStyle = .light
canvasView.translatesAutoresizingMaskIntoConstraints = false canvasView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(canvasView) view.addSubview(canvasView)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
@ -74,6 +99,7 @@ class ComposeDrawingViewController: UIViewController {
]) ])
toolPicker = PKToolPicker() toolPicker = PKToolPicker()
toolPicker.overrideUserInterfaceStyle = .light
toolPicker.setVisible(true, forFirstResponder: canvasView) toolPicker.setVisible(true, forFirstResponder: canvasView)
toolPicker.addObserver(canvasView) toolPicker.addObserver(canvasView)
toolPicker.addObserver(self) toolPicker.addObserver(self)

View File

@ -360,11 +360,7 @@ extension ComposeHostingController: ComposeUIStateDelegate {
drawing = PKDrawing() drawing = PKDrawing()
} }
let drawingVC = ComposeDrawingViewController(editing: drawing) present(ComposeDrawingNavigationController(editing: drawing, delegate: self), animated: true)
drawingVC.delegate = self
let nav = UINavigationController(rootViewController: drawingVC)
nav.modalPresentationStyle = .fullScreen
present(nav, animated: true)
} }
} }

View File

@ -14,6 +14,7 @@ struct ComposingPrefsView: View {
var body: some View { var body: some View {
List { List {
visibilitySection
composingSection composingSection
replyingSection replyingSection
} }
@ -21,9 +22,9 @@ struct ComposingPrefsView: View {
.navigationBarTitle("Composing") .navigationBarTitle("Composing")
} }
var composingSection: some View { var visibilitySection: some View {
Section(header: Text("Composing")) { Section {
Picker(selection: $preferences.defaultPostVisibility, label: Text("Default Post Visibility")) { Picker(selection: $preferences.defaultPostVisibility, label: Text("Default Visibility")) {
ForEach(Status.Visibility.allCases, id: \.self) { visibility in ForEach(Status.Visibility.allCases, id: \.self) { visibility in
HStack { HStack {
Image(systemName: visibility.imageName) Image(systemName: visibility.imageName)
@ -33,6 +34,26 @@ struct ComposingPrefsView: View {
}//.navigationBarTitle("Default Post Visibility") }//.navigationBarTitle("Default Post Visibility")
// navbar title on the ForEach is currently incorrectly applied when the picker is not expanded, see FB6838291 // navbar title on the ForEach is currently incorrectly applied when the picker is not expanded, see FB6838291
} }
Picker(selection: $preferences.defaultReplyVisibility, label: Text("Reply Visibility")) {
ForEach(Preferences.ReplyVisibility.allCases, id: \.self) { visibility in
HStack {
if let imageName = visibility.imageName {
Image(systemName: imageName)
}
Text(visibility.displayName)
}
.tag(visibility)
}
}
} header: {
Text("Visibility")
} footer: {
Text("When starting a reply, Tusker will use your preferred visibility or the visibility of the post to which you're replying, whichever is narrower.")
}
}
var composingSection: some View {
Section(header: Text("Composing")) {
Toggle(isOn: $preferences.automaticallySaveDrafts) { Toggle(isOn: $preferences.automaticallySaveDrafts) {
Text("Automatically Save Drafts") Text("Automatically Save Drafts")
} }

View File

@ -24,6 +24,12 @@ struct MediaPrefsView: View {
Toggle(isOn: $preferences.blurAllMedia) { Toggle(isOn: $preferences.blurAllMedia) {
Text("Blur All Media") Text("Blur All Media")
} }
Toggle(isOn: $preferences.blurMediaBehindContentWarning) {
Text("Blur Media Behind Content Warning")
}
.disabled(preferences.blurAllMedia)
Toggle(isOn: $preferences.automaticallyPlayGifs) { Toggle(isOn: $preferences.automaticallyPlayGifs) {
Text("Automatically Play GIFs") Text("Automatically Play GIFs")
} }

View File

@ -222,7 +222,17 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
} }
var snapshot = dataSource.snapshot() var snapshot = dataSource.snapshot()
let items = statuses.map { Item.status(id: $0.id, state: .unknown, pinned: true) } let existingPinned = snapshot.itemIdentifiers(inSection: .pinned)
let items = statuses.map {
let item = Item.status(id: $0.id, state: .unknown, pinned: true)
// try to keep the existing status state
if let existing = existingPinned.first(where: { $0 == item }) {
return existing
} else {
return item
}
}
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .pinned))
snapshot.appendItems(items, toSection: .pinned) snapshot.appendItems(items, toSection: .pinned)
await apply(snapshot, animatingDifferences: true) await apply(snapshot, animatingDifferences: true)
} }

View File

@ -236,27 +236,9 @@ class AttachmentsContainerView: UIView {
// Make sure accessibilityElements is set every time the UI is updated, otherwise it holds // Make sure accessibilityElements is set every time the UI is updated, otherwise it holds
// on to strong references to the old set of attachment views // on to strong references to the old set of attachment views
self.accessibilityElements = accessibilityElements self.accessibilityElements = accessibilityElements
contentHidden = Preferences.shared.blurAllMedia || status.sensitive
} }
private func createAttachmentView(index: Int, hSize: RelativeSize, vSize: RelativeSize) -> AttachmentView { private func createAttachmentView(index: Int, hSize: RelativeSize, vSize: RelativeSize) -> AttachmentView {
let width: CGFloat
switch hSize {
case .full:
width = bounds.width
case .half:
width = (bounds.width - 4) / 2
}
let height: CGFloat
switch vSize {
case .full:
height = bounds.height
case .half:
height = (bounds.height - 4) / 2
}
let size = CGSize(width: width, height: height)
let attachmentView = AttachmentView(attachment: attachments[index], index: index) let attachmentView = AttachmentView(attachment: attachments[index], index: index)
attachmentView.delegate = delegate attachmentView.delegate = delegate
attachmentView.translatesAutoresizingMaskIntoConstraints = false attachmentView.translatesAutoresizingMaskIntoConstraints = false

View File

@ -86,28 +86,31 @@ extension BaseEmojiLabel {
func buildStringWithEmojisReplaced(usePlaceholders: Bool) -> NSAttributedString { func buildStringWithEmojisReplaced(usePlaceholders: Bool) -> NSAttributedString {
let mutAttrString = NSMutableAttributedString(attributedString: attributedString) let mutAttrString = NSMutableAttributedString(attributedString: attributedString)
// lock once for the entire loop, rather than lock/unlocking for each iteration to do the lookup
// OSAllocatedUnfairLock.withLock expects a @Sendable closure, so this warns about captures of non-sendable types (attribute dstrings, text checking results) // OSAllocatedUnfairLock.withLock expects a @Sendable closure, so this warns about captures of non-sendable types (attribute dstrings, text checking results)
// 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
emojiImages.withLock { emojiImages in let emojiAttachments = emojiImages.withLock {
// replaces the emojis starting from the end of the string as to not alter the indices of preceeding emojis $0.mapValues { image in
for match in matches.reversed() { NSTextAttachment(emojiImage: image, in: self.emojiFont, with: self.emojiTextColor)
let shortcode = (attributedString.string as NSString).substring(with: match.range(at: 1))
let attachment: NSTextAttachment
if let emojiImage = emojiImages[shortcode] {
attachment = NSTextAttachment(emojiImage: emojiImage, in: self.emojiFont, with: self.emojiTextColor)
} else if usePlaceholders {
attachment = NSTextAttachment(emojiPlaceholderIn: self.emojiFont)
} else {
continue
}
let attachmentStr = NSAttributedString(attachment: attachment)
mutAttrString.replaceCharacters(in: match.range, with: attachmentStr)
} }
} }
let placeholder = usePlaceholders ? NSTextAttachment(emojiPlaceholderIn: self.emojiFont) : nil
// replaces the emojis starting from the end of the string as to not alter the indices of preceeding emojis
for match in matches.reversed() {
let shortcode = (attributedString.string as NSString).substring(with: match.range(at: 1))
let attachment: NSTextAttachment
if let emoji = emojiAttachments[shortcode] {
attachment = emoji
} else if usePlaceholders {
attachment = placeholder!
} else {
continue
}
let attachmentStr = NSAttributedString(attachment: attachment)
mutAttrString.replaceCharacters(in: match.range, with: attachmentStr)
}
return mutAttrString return mutAttrString
} }

View File

@ -182,7 +182,8 @@ class BaseStatusTableViewCell: UITableViewCell {
updateStatusIconsForPreferences(status) updateStatusIconsForPreferences(status)
if state.unknown { if state.unknown {
state.resolveFor(status: status, text: contentTextView.text) layoutIfNeeded()
state.resolveFor(status: status, height: contentTextView.bounds.height)
if state.collapsible! && showStatusAutomatically { if state.collapsible! && showStatusAutomatically {
state.collapsed = false state.collapsed = false
} }
@ -230,7 +231,17 @@ class BaseStatusTableViewCell: UITableViewCell {
func updateUIForPreferences(account: AccountMO, status: StatusMO) { func updateUIForPreferences(account: AccountMO, status: StatusMO) {
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView) avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
attachmentsView.contentHidden = Preferences.shared.blurAllMedia || status.sensitive if Preferences.shared.blurAllMedia {
attachmentsView.contentHidden = true
} else if status.sensitive {
if !Preferences.shared.blurMediaBehindContentWarning && !status.spoilerText.isEmpty {
attachmentsView.contentHidden = false
} else {
attachmentsView.contentHidden = true
}
} else {
attachmentsView.contentHidden = false
}
updateStatusIconsForPreferences(status) updateStatusIconsForPreferences(status)

View File

@ -104,7 +104,9 @@ extension StatusCollectionViewCell {
favoriteButton.isEnabled = mastodonController.loggedIn favoriteButton.isEnabled = mastodonController.loggedIn
if statusState.unknown { if statusState.unknown {
statusState.resolveFor(status: status, text: contentContainer.contentTextView.text) // layout so that we can take the content height into consideration when deciding whether to collapse
layoutIfNeeded()
statusState.resolveFor(status: status, height: contentContainer.contentTextView.bounds.height)
if statusState.collapsible! && showStatusAutomatically { if statusState.collapsible! && showStatusAutomatically {
statusState.collapsed = false statusState.collapsed = false
} }
@ -145,7 +147,17 @@ extension StatusCollectionViewCell {
func baseUpdateUIForPreferences(status: StatusMO) { func baseUpdateUIForPreferences(status: StatusMO) {
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * Self.avatarImageViewSize avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * Self.avatarImageViewSize
contentContainer.attachmentsView.contentHidden = Preferences.shared.blurAllMedia || status.sensitive if Preferences.shared.blurAllMedia {
contentContainer.attachmentsView.contentHidden = true
} else if status.sensitive {
if !Preferences.shared.blurMediaBehindContentWarning && !status.spoilerText.isEmpty {
contentContainer.attachmentsView.contentHidden = false
} else {
contentContainer.attachmentsView.contentHidden = true
}
} else {
contentContainer.attachmentsView.contentHidden = false
}
let reblogButtonImage: UIImage let reblogButtonImage: UIImage
if Preferences.shared.alwaysShowStatusVisibilityIcon || reblogEnabled(status: status) { if Preferences.shared.alwaysShowStatusVisibilityIcon || reblogEnabled(status: status) {