Compare commits

..

7 Commits

Author SHA1 Message Date
Shadowfacts a79b3cfd70 Fix gallery controls not being accessible, fix escape gesture not working
Closes #292
2022-12-06 22:21:59 -05:00
Shadowfacts 9a35f96c75 VoiceOver: Include attachment descriptions in timeline statuses
Closes #291
2022-12-06 22:14:23 -05:00
Shadowfacts 60767c6a7e Profile Directory screen VoiceOver improvements
Add label to filter button (and change icon to match other filters)

Make each profile a single accessibility element
2022-12-06 21:54:17 -05:00
Shadowfacts 57668886b2 Fix crash when scrolling through Local/Federated timeline with VoiceOver
It seems that the accessibility scroll mechanism does something like:
1. Find the next IndexPath to focus
2. Scroll to make it visible
3. Focus that cell

But because the timeline description cell is removed during the scroll,
the IndexPath that the accessibility system wants to focus becomes
invalid between steps 2 and 3, causing a crash when trying to focus it.

As a workaround, only remove the timeline description _item_ rather than
the header section so that section indices aren't affected.

Closes #290
2022-12-06 21:46:32 -05:00
Shadowfacts ffb5c76f7c Add preference to never blur attachments 2022-12-06 21:12:58 -05:00
Shadowfacts 00e8dd6345 Fix crash when previeiwng non-HTTP(S) link 2022-12-06 10:58:13 -05:00
Shadowfacts 7904462920 Fix serializing the nodeinfo version instead of the software version in breadcrumb 2022-12-05 22:24:33 -05:00
13 changed files with 153 additions and 59 deletions

View File

@ -257,8 +257,8 @@ private func setInstanceBreadcrumb(instance: Instance, nodeInfo: NodeInfo?) {
] ]
if let nodeInfo { if let nodeInfo {
crumb.data!["nodeInfo"] = [ crumb.data!["nodeInfo"] = [
"version": nodeInfo.version, "software": nodeInfo.software.name,
"software": nodeInfo.software, "version": nodeInfo.software.version,
] ]
} }
SentrySDK.addBreadcrumb(crumb: crumb) SentrySDK.addBreadcrumb(crumb: crumb)

View File

@ -52,7 +52,11 @@ class Preferences: Codable, ObservableObject {
self.mentionReblogger = try container.decode(Bool.self, forKey: .mentionReblogger) self.mentionReblogger = try container.decode(Bool.self, forKey: .mentionReblogger)
self.useTwitterKeyboard = try container.decodeIfPresent(Bool.self, forKey: .useTwitterKeyboard) ?? false self.useTwitterKeyboard = try container.decodeIfPresent(Bool.self, forKey: .useTwitterKeyboard) ?? false
self.blurAllMedia = try container.decode(Bool.self, forKey: .blurAllMedia) if let blurAllMedia = try? container.decodeIfPresent(Bool.self, forKey: .blurAllMedia) {
self.attachmentBlurMode = blurAllMedia ? .always : .useStatusSetting
} else {
self.attachmentBlurMode = try container.decode(AttachmentBlurMode.self, forKey: .attachmentBlurMode)
}
self.blurMediaBehindContentWarning = try container.decodeIfPresent(Bool.self, forKey: .blurMediaBehindContentWarning) ?? true 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)
@ -95,7 +99,7 @@ class Preferences: Codable, ObservableObject {
try container.encode(mentionReblogger, forKey: .mentionReblogger) try container.encode(mentionReblogger, forKey: .mentionReblogger)
try container.encode(useTwitterKeyboard, forKey: .useTwitterKeyboard) try container.encode(useTwitterKeyboard, forKey: .useTwitterKeyboard)
try container.encode(blurAllMedia, forKey: .blurAllMedia) try container.encode(attachmentBlurMode, forKey: .attachmentBlurMode)
try container.encode(blurMediaBehindContentWarning, forKey: .blurMediaBehindContentWarning) try container.encode(blurMediaBehindContentWarning, forKey: .blurMediaBehindContentWarning)
try container.encode(automaticallyPlayGifs, forKey: .automaticallyPlayGifs) try container.encode(automaticallyPlayGifs, forKey: .automaticallyPlayGifs)
@ -140,10 +144,12 @@ class Preferences: Codable, ObservableObject {
@Published var useTwitterKeyboard = false @Published var useTwitterKeyboard = false
// MARK: Media // MARK: Media
@Published var blurAllMedia = false { @Published var attachmentBlurMode = AttachmentBlurMode.useStatusSetting {
didSet { didSet {
if blurAllMedia { if attachmentBlurMode == .always {
blurMediaBehindContentWarning = true blurMediaBehindContentWarning = true
} else if attachmentBlurMode == .never {
blurMediaBehindContentWarning = false
} }
} }
} }
@ -191,7 +197,8 @@ class Preferences: Codable, ObservableObject {
case mentionReblogger case mentionReblogger
case useTwitterKeyboard case useTwitterKeyboard
case blurAllMedia case blurAllMedia // only used for migration
case attachmentBlurMode
case blurMediaBehindContentWarning case blurMediaBehindContentWarning
case automaticallyPlayGifs case automaticallyPlayGifs
@ -254,4 +261,23 @@ extension Preferences {
} }
} }
extension Preferences {
enum AttachmentBlurMode: Codable, Hashable, CaseIterable {
case useStatusSetting
case always
case never
var displayName: String {
switch self {
case .useStatusSetting:
return "Default"
case .always:
return "Always"
case .never:
return "Never"
}
}
}
}
extension UIUserInterfaceStyle: Codable {} extension UIUserInterfaceStyle: Codable {}

View File

@ -136,6 +136,11 @@ class GalleryViewController: UIPageViewController, UIPageViewControllerDataSourc
vc.player?.play() vc.player?.play()
} }
} }
override func accessibilityPerformEscape() -> Bool {
dismiss(animated: true)
return true
}
// MARK: - Page View Controller Data Source // MARK: - Page View Controller Data Source

View File

@ -122,5 +122,24 @@ class FeaturedProfileCollectionViewCell: UICollectionViewCell {
displayNameLabel.updateForAccountDisplayName(account: account) displayNameLabel.updateForAccountDisplayName(account: account)
} }
} }
// MARK: Accessibility
override var isAccessibilityElement: Bool {
get { true }
set {}
}
override var accessibilityAttributedLabel: NSAttributedString? {
get {
guard let account else {
return nil
}
let s = NSMutableAttributedString(string: "\(account.displayName), ")
s.append(noteTextView.attributedText)
return s
}
set {}
}
} }

View File

@ -34,8 +34,9 @@ class ProfileDirectoryViewController: UIViewController {
title = NSLocalizedString("Profile Directory", comment: "profile directory title") title = NSLocalizedString("Profile Directory", comment: "profile directory title")
// todo: it would be nice if there were a better "filter" icon let filterItem = UIBarButtonItem(image: UIImage(systemName: "line.3.horizontal.decrease.circle"), menu: nil)
navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "scope"), menu: nil) filterItem.accessibilityLabel = "Filter"
navigationItem.rightBarButtonItem = filterItem
updateFilterMenu() updateFilterMenu()
let layout = UICollectionViewCompositionalLayout(sectionProvider: { (sectionIndex, layoutEnvironment) in let layout = UICollectionViewCompositionalLayout(sectionProvider: { (sectionIndex, layoutEnvironment) in

View File

@ -21,6 +21,7 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
@IBOutlet weak var descriptionLabel: UILabel! @IBOutlet weak var descriptionLabel: UILabel!
private var shareContainer: UIView! private var shareContainer: UIView!
private var closeContainer: UIView!
private var shareImage: UIImageView! private var shareImage: UIImageView!
private var shareButtonTopConstraint: NSLayoutConstraint! private var shareButtonTopConstraint: NSLayoutConstraint!
private var shareButtonLeadingConstraint: NSLayoutConstraint! private var shareButtonLeadingConstraint: NSLayoutConstraint!
@ -116,6 +117,12 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
view.addGestureRecognizer(doubleTap) view.addGestureRecognizer(doubleTap)
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
accessibilityElements = [
topControlsView!,
contentView,
bottomControlsView!,
]
} }
private func setupContentView() { private func setupContentView() {
@ -135,6 +142,9 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
private func setupControls() { private func setupControls() {
shareContainer = UIView() shareContainer = UIView()
shareContainer.isAccessibilityElement = true
shareContainer.accessibilityTraits = .button
shareContainer.accessibilityLabel = "Share"
shareContainer.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(sharePressed))) shareContainer.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(sharePressed)))
shareContainer.translatesAutoresizingMaskIntoConstraints = false shareContainer.translatesAutoresizingMaskIntoConstraints = false
topControlsView.addSubview(shareContainer) topControlsView.addSubview(shareContainer)
@ -161,7 +171,10 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
shareImage.heightAnchor.constraint(equalToConstant: 24), shareImage.heightAnchor.constraint(equalToConstant: 24),
]) ])
let closeContainer = UIView() closeContainer = UIView()
closeContainer.isAccessibilityElement = true
closeContainer.accessibilityTraits = .button
closeContainer.accessibilityLabel = "Close"
closeContainer.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(closeButtonPressed))) closeContainer.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(closeButtonPressed)))
closeContainer.translatesAutoresizingMaskIntoConstraints = false closeContainer.translatesAutoresizingMaskIntoConstraints = false
topControlsView.addSubview(closeContainer) topControlsView.addSubview(closeContainer)

View File

@ -21,14 +21,18 @@ struct MediaPrefsView: View {
var viewingSection: some View { var viewingSection: some View {
Section(header: Text("Viewing")) { Section(header: Text("Viewing")) {
Toggle(isOn: $preferences.blurAllMedia) { Picker(selection: $preferences.attachmentBlurMode) {
Text("Blur All Media") ForEach(Preferences.AttachmentBlurMode.allCases, id: \.self) { mode in
Text(mode.displayName).tag(mode)
}
} label: {
Text("Blur Media")
} }
Toggle(isOn: $preferences.blurMediaBehindContentWarning) { Toggle(isOn: $preferences.blurMediaBehindContentWarning) {
Text("Blur Media Behind Content Warning") Text("Blur Media Behind Content Warning")
} }
.disabled(preferences.blurAllMedia) .disabled(preferences.attachmentBlurMode != .useStatusSetting)
Toggle(isOn: $preferences.automaticallyPlayGifs) { Toggle(isOn: $preferences.automaticallyPlayGifs) {
Text("Automatically Play GIFs") Text("Automatically Play GIFs")

View File

@ -345,7 +345,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
private func removeTimelineDescriptionCell() { private func removeTimelineDescriptionCell() {
var snapshot = dataSource.snapshot() var snapshot = dataSource.snapshot()
snapshot.deleteSections([.header]) snapshot.deleteItems([.publicTimelineDescription])
dataSource.apply(snapshot, animatingDifferences: true) dataSource.apply(snapshot, animatingDifferences: true)
isShowingTimelineDescription = false isShowingTimelineDescription = false
} }

View File

@ -173,15 +173,17 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
// MARK: - Navigation // MARK: - Navigation
func getViewController(forLink url: URL, inRange range: NSRange) -> UIViewController { func getViewController(forLink url: URL, inRange range: NSRange) -> UIViewController? {
let text = (self.text as NSString).substring(with: range) let text = (self.text as NSString).substring(with: range)
if let mention = getMention(for: url, text: text) { if let mention = getMention(for: url, text: text) {
return ProfileViewController(accountID: mention.id, mastodonController: mastodonController!) return ProfileViewController(accountID: mention.id, mastodonController: mastodonController!)
} else if let tag = getHashtag(for: url, text: text) { } else if let tag = getHashtag(for: url, text: text) {
return HashtagTimelineViewController(for: tag, mastodonController: mastodonController!) return HashtagTimelineViewController(for: tag, mastodonController: mastodonController!)
} else { } else if url.scheme == "https" || url.scheme == "http" {
return SFSafariViewController(url: url) return SFSafariViewController(url: url)
} else {
return nil
} }
} }

View File

@ -231,16 +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)
if Preferences.shared.blurAllMedia { switch Preferences.shared.attachmentBlurMode {
attachmentsView.contentHidden = true case .never:
} else if status.sensitive {
if !Preferences.shared.blurMediaBehindContentWarning && !status.spoilerText.isEmpty {
attachmentsView.contentHidden = false
} else {
attachmentsView.contentHidden = true
}
} else {
attachmentsView.contentHidden = false attachmentsView.contentHidden = false
case .always:
attachmentsView.contentHidden = true
default:
if status.sensitive {
attachmentsView.contentHidden = status.spoilerText.isEmpty || Preferences.shared.blurMediaBehindContentWarning
} else {
attachmentsView.contentHidden = false
}
} }
updateStatusIconsForPreferences(status) updateStatusIconsForPreferences(status)

View File

@ -148,16 +148,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
if Preferences.shared.blurAllMedia { switch Preferences.shared.attachmentBlurMode {
contentContainer.attachmentsView.contentHidden = true case .never:
} 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 contentContainer.attachmentsView.contentHidden = false
case .always:
contentContainer.attachmentsView.contentHidden = true
default:
if status.sensitive {
contentContainer.attachmentsView.contentHidden = status.spoilerText.isEmpty || Preferences.shared.blurMediaBehindContentWarning
} else {
contentContainer.attachmentsView.contentHidden = false
}
} }
let reblogButtonImage: UIImage let reblogButtonImage: UIImage

View File

@ -369,7 +369,13 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
guard let status = mastodonController.persistentContainer.status(for: statusID) else { guard let status = mastodonController.persistentContainer.status(for: statusID) else {
return nil return nil
} }
var str = AttributedString("\(status.account.displayOrUserName), ") var str: AttributedString = ""
if let rebloggerID,
let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {
str += AttributedString("Reblogged by \(reblogger.displayOrUserName): ")
}
str += AttributedString(status.account.displayOrUserName)
str += ", "
if statusState.collapsed ?? false { if statusState.collapsed ?? false {
if !status.spoilerText.isEmpty { if !status.spoilerText.isEmpty {
str += AttributedString(status.spoilerText) str += AttributedString(status.spoilerText)
@ -378,15 +384,24 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
str += "collapsed" str += "collapsed"
} else { } else {
str += AttributedString(contentTextView.attributedText) str += AttributedString(contentTextView.attributedText)
}
if status.attachments.count > 0 { if status.attachments.count > 0 {
// TODO: localize me if status.attachments.count == 1 {
str += AttributedString(", \(status.attachments.count) attachment\(status.attachments.count > 1 ? "s" : "")") let attachment = status.attachments[0]
} let desc = attachment.description?.isEmpty == false ? attachment.description! : "no description"
if status.poll != nil { str += AttributedString(", attachment: \(desc)")
str += ", poll" } else {
for (index, attachment) in status.attachments.enumerated() {
let desc = attachment.description?.isEmpty == false ? attachment.description! : "no description"
str += AttributedString(", attachment \(index + 1): \(desc)")
}
}
}
if status.poll != nil {
str += ", poll"
}
} }
str += AttributedString(", \(status.createdAt.formatted(.relative(presentation: .numeric)))") str += AttributedString(", \(status.createdAt.formatted(.relative(presentation: .numeric)))")
if status.visibility < .unlisted { if status.visibility < .unlisted {
str += AttributedString(", \(status.visibility.displayName)") str += AttributedString(", \(status.visibility.displayName)")
@ -394,10 +409,6 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
if status.localOnly { if status.localOnly {
str += ", local" str += ", local"
} }
if let rebloggerID,
let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {
str += AttributedString(", reblogged by \(reblogger.displayOrUserName)")
}
return NSAttributedString(str) return NSAttributedString(str)
} }
set {} set {}

View File

@ -254,7 +254,13 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
guard let status = mastodonController.persistentContainer.status(for: statusID) else { guard let status = mastodonController.persistentContainer.status(for: statusID) else {
return nil return nil
} }
var str = AttributedString("\(status.account.displayOrUserName), ") var str: AttributedString = ""
if let rebloggerID,
let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {
str += AttributedString("Reblogged by \(reblogger.displayOrUserName): ")
}
str += AttributedString(status.account.displayOrUserName)
str += ", "
if statusState.collapsed ?? false { if statusState.collapsed ?? false {
if !status.spoilerText.isEmpty { if !status.spoilerText.isEmpty {
str += AttributedString(status.spoilerText) str += AttributedString(status.spoilerText)
@ -263,15 +269,24 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
str += "collapsed" str += "collapsed"
} else { } else {
str += AttributedString(contentTextView.attributedText) str += AttributedString(contentTextView.attributedText)
}
if status.attachments.count > 0 { if status.attachments.count > 0 {
// TODO: localize me if status.attachments.count == 1 {
str += AttributedString(", \(status.attachments.count) attachment\(status.attachments.count > 1 ? "s" : "")") let attachment = status.attachments[0]
} let desc = attachment.description?.isEmpty == false ? attachment.description! : "no description"
if status.poll != nil { str += AttributedString(", attachment: \(desc)")
str += ", poll" } else {
for (index, attachment) in status.attachments.enumerated() {
let desc = attachment.description?.isEmpty == false ? attachment.description! : "no description"
str += AttributedString(", attachment \(index + 1): \(desc)")
}
}
}
if status.poll != nil {
str += ", poll"
}
} }
str += AttributedString(", \(status.createdAt.formatted(.relative(presentation: .numeric)))") str += AttributedString(", \(status.createdAt.formatted(.relative(presentation: .numeric)))")
if status.visibility < .unlisted { if status.visibility < .unlisted {
str += AttributedString(", \(status.visibility.displayName)") str += AttributedString(", \(status.visibility.displayName)")
@ -279,10 +294,6 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
if status.localOnly { if status.localOnly {
str += ", local" str += ", local"
} }
if let rebloggerID,
let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {
str += AttributedString(", reblogged by \(reblogger.displayOrUserName)")
}
return NSAttributedString(str) return NSAttributedString(str)
} }
set {} set {}