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 {
crumb.data!["nodeInfo"] = [
"version": nodeInfo.version,
"software": nodeInfo.software,
"software": nodeInfo.software.name,
"version": nodeInfo.software.version,
]
}
SentrySDK.addBreadcrumb(crumb: crumb)

View File

@ -52,7 +52,11 @@ class Preferences: Codable, ObservableObject {
self.mentionReblogger = try container.decode(Bool.self, forKey: .mentionReblogger)
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.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(useTwitterKeyboard, forKey: .useTwitterKeyboard)
try container.encode(blurAllMedia, forKey: .blurAllMedia)
try container.encode(attachmentBlurMode, forKey: .attachmentBlurMode)
try container.encode(blurMediaBehindContentWarning, forKey: .blurMediaBehindContentWarning)
try container.encode(automaticallyPlayGifs, forKey: .automaticallyPlayGifs)
@ -140,10 +144,12 @@ class Preferences: Codable, ObservableObject {
@Published var useTwitterKeyboard = false
// MARK: Media
@Published var blurAllMedia = false {
@Published var attachmentBlurMode = AttachmentBlurMode.useStatusSetting {
didSet {
if blurAllMedia {
if attachmentBlurMode == .always {
blurMediaBehindContentWarning = true
} else if attachmentBlurMode == .never {
blurMediaBehindContentWarning = false
}
}
}
@ -191,7 +197,8 @@ class Preferences: Codable, ObservableObject {
case mentionReblogger
case useTwitterKeyboard
case blurAllMedia
case blurAllMedia // only used for migration
case attachmentBlurMode
case blurMediaBehindContentWarning
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 {}

View File

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

View File

@ -122,5 +122,24 @@ class FeaturedProfileCollectionViewCell: UICollectionViewCell {
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")
// todo: it would be nice if there were a better "filter" icon
navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "scope"), menu: nil)
let filterItem = UIBarButtonItem(image: UIImage(systemName: "line.3.horizontal.decrease.circle"), menu: nil)
filterItem.accessibilityLabel = "Filter"
navigationItem.rightBarButtonItem = filterItem
updateFilterMenu()
let layout = UICollectionViewCompositionalLayout(sectionProvider: { (sectionIndex, layoutEnvironment) in

View File

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

View File

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

View File

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

View File

@ -173,15 +173,17 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
// 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)
if let mention = getMention(for: url, text: text) {
return ProfileViewController(accountID: mention.id, mastodonController: mastodonController!)
} else if let tag = getHashtag(for: url, text: text) {
return HashtagTimelineViewController(for: tag, mastodonController: mastodonController!)
} else {
} else if url.scheme == "https" || url.scheme == "http" {
return SFSafariViewController(url: url)
} else {
return nil
}
}

View File

@ -231,16 +231,17 @@ class BaseStatusTableViewCell: UITableViewCell {
func updateUIForPreferences(account: AccountMO, status: StatusMO) {
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
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 {
switch Preferences.shared.attachmentBlurMode {
case .never:
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)

View File

@ -148,16 +148,17 @@ extension StatusCollectionViewCell {
func baseUpdateUIForPreferences(status: StatusMO) {
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * Self.avatarImageViewSize
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 {
switch Preferences.shared.attachmentBlurMode {
case .never:
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

View File

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

View File

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