diff --git a/Ring/AccessibilityIdentifiers.swift b/Ring/AccessibilityIdentifiers.swift index ab6f56d8613470cc378598c422a0a34a0dc8704d..183e7be9b60b0e84db571f8eded08faca986ef74 100644 --- a/Ring/AccessibilityIdentifiers.swift +++ b/Ring/AccessibilityIdentifiers.swift @@ -48,4 +48,6 @@ struct SmartListAccessibilityIdentifiers { static let accountsListTitle = "accountsListTitle" static let closeAccountsList = "closeAccountsList" static let closeAboutView = "closeAboutView" + static let requestsCloseButton = "requestsCloseButton" + } diff --git a/Ring/Ring/Constants/Generated/Strings.swift b/Ring/Ring/Constants/Generated/Strings.swift index 28e87b7cc048318905dbc3edecc6c977adeb15f2..cd0c517d2176b6e7e51973a6c640eb6d5b2dbd7d 100644 --- a/Ring/Ring/Constants/Generated/Strings.swift +++ b/Ring/Ring/Constants/Generated/Strings.swift @@ -39,10 +39,66 @@ internal enum L10n { internal static let accountSummaryQrCode = L10n.tr("Localizable", "accessibility.accountSummaryQrCode", fallback: "QR Code") /// Double-tap to view your account QR code internal static let accountSummaryQrCodeHint = L10n.tr("Localizable", "accessibility.accountSummaryQrCodeHint", fallback: "Double-tap to view your account QR code") + /// Pause + internal static let audioPlayerPause = L10n.tr("Localizable", "accessibility.audioPlayerPause", fallback: "Pause") + /// Play + internal static let audioPlayerPlay = L10n.tr("Localizable", "accessibility.audioPlayerPlay", fallback: "Play") /// Close internal static let close = L10n.tr("Localizable", "accessibility.close", fallback: "Close") + /// Double-tap to open camera + internal static let conversationCameraHint = L10n.tr("Localizable", "accessibility.conversationCameraHint", fallback: "Double-tap to open camera") + /// Compose a message + internal static let conversationComposeMessage = L10n.tr("Localizable", "accessibility.conversationComposeMessage", fallback: "Compose a message") + /// Conversation blocked + internal static let conversationRowBlocked = L10n.tr("Localizable", "accessibility.conversationRowBlocked", fallback: "Conversation blocked") + /// Last message on %@ + internal static func conversationRowLastMessage(_ p1: Any) -> String { + return L10n.tr("Localizable", "accessibility.conversationRowLastMessage", String(describing: p1), fallback: "Last message on %@") + } + /// Syncing in progress + internal static let conversationRowSyncing = L10n.tr("Localizable", "accessibility.conversationRowSyncing", fallback: "Syncing in progress") + /// %@ unread messages + internal static func conversationRowUnreadCount(_ p1: Any) -> String { + return L10n.tr("Localizable", "accessibility.conversationRowUnreadCount", String(describing: p1), fallback: "%@ unread messages") + } + /// Share media + internal static let conversationShareMedia = L10n.tr("Localizable", "accessibility.conversationShareMedia", fallback: "Share media") + /// Start a video call with %@ + internal static func conversationStartVideoCall(_ p1: Any) -> String { + return L10n.tr("Localizable", "accessibility.conversationStartVideoCall", String(describing: p1), fallback: "Start a video call with %@") + } + /// Start a voice call with %@ + internal static func conversationStartVoiceCall(_ p1: Any) -> String { + return L10n.tr("Localizable", "accessibility.conversationStartVoiceCall", String(describing: p1), fallback: "Start a voice call with %@") + } /// Enter a username to verify if it's available internal static let createAccountVerifyUsernamePrompt = L10n.tr("Localizable", "accessibility.createAccountVerifyUsernamePrompt", fallback: "Enter a username to verify if it's available") + /// File received on %@, name not available + internal static func fileTransferNoName(_ p1: Any) -> String { + return L10n.tr("Localizable", "accessibility.fileTransferNoName", String(describing: p1), fallback: "File received on %@, name not available") + } + /// In reply to a message + internal static let inReply = L10n.tr("Localizable", "accessibility.inReply", fallback: "In reply to a message") + /// Message deleted + internal static let messageBubbleDeleted = L10n.tr("Localizable", "accessibility.messageBubbleDeleted", fallback: "Message deleted") + /// Edited + internal static let messageBubbleEdited = L10n.tr("Localizable", "accessibility.messageBubbleEdited", fallback: "Edited") + /// Read + internal static let messageBubbleRead = L10n.tr("Localizable", "accessibility.messageBubbleRead", fallback: "Read") + /// Unread + internal static let messageBubbleUnread = L10n.tr("Localizable", "accessibility.messageBubbleUnread", fallback: "Unread") + /// Accept invitation + internal static let pendingRequestsListAcceptInvitation = L10n.tr("Localizable", "accessibility.pendingRequestsListAcceptInvitation", fallback: "Accept invitation") + /// Block invitation sender + internal static let pendingRequestsListBlockUser = L10n.tr("Localizable", "accessibility.pendingRequestsListBlockUser", fallback: "Block invitation sender") + /// Reject invitation + internal static let pendingRequestsListRejectInvitation = L10n.tr("Localizable", "accessibility.pendingRequestsListRejectInvitation", fallback: "Reject invitation") + /// Invitation received: %@ pending invitation + internal static func pendingRequestsRow(_ p1: Any) -> String { + return L10n.tr("Localizable", "accessibility.pendingRequestsRow", String(describing: p1), fallback: "Invitation received: %@ pending invitation") + } + /// Double-tap to review and reply to invitations you received + internal static let pendingRequestsRowHint = L10n.tr("Localizable", "accessibility.pendingRequestsRowHint", fallback: "Double-tap to review and reply to invitations you received") /// Profile picture internal static let profilePicturePicker = L10n.tr("Localizable", "accessibility.profilePicturePicker", fallback: "Profile picture") /// Double-tap to take a picture or select a picture from the library @@ -59,12 +115,40 @@ internal enum L10n { internal static let swarmPicturePicker = L10n.tr("Localizable", "accessibility.swarmPicturePicker", fallback: "Group picture") /// Double-tap to take a picture or select a picture from the library internal static let swarmPicturePickerHint = L10n.tr("Localizable", "accessibility.swarmPicturePickerHint", fallback: "Double-tap to take a picture or select a picture from the library") - /// Off - internal static let switchButtonIsOff = L10n.tr("Localizable", "accessibility.switchButtonIsOff", fallback: "Off") - /// On - internal static let switchButtonIsOn = L10n.tr("Localizable", "accessibility.switchButtonIsOn", fallback: "On") + /// Text message received on %@, content not available + internal static func textNotAvailable(_ p1: Any) -> String { + return L10n.tr("Localizable", "accessibility.textNotAvailable", String(describing: p1), fallback: "Text message received on %@, content not available") + } + /// Available + internal static let userPresenceAvailable = L10n.tr("Localizable", "accessibility.userPresenceAvailable", fallback: "Available") + /// Online + internal static let userPresenceOnline = L10n.tr("Localizable", "accessibility.userPresenceOnline", fallback: "Online") /// Welcome to Jami internal static let welcomeToJamiTitle = L10n.tr("Localizable", "accessibility.welcomeToJamiTitle", fallback: "Welcome to Jami") + internal enum Call { + /// Lasted + internal static let lasted = L10n.tr("Localizable", "accessibility.call.lasted", fallback: "Lasted") + } + internal enum FileTransfer { + /// File: %@ , received on %@ + internal static func receivedOn(_ p1: Any, _ p2: Any) -> String { + return L10n.tr("Localizable", "accessibility.fileTransfer.receivedOn", String(describing: p1), String(describing: p2), fallback: "File: %@ , received on %@") + } + /// File: %@, sent on %@ + internal static func sentOn(_ p1: Any, _ p2: Any) -> String { + return L10n.tr("Localizable", "accessibility.fileTransfer.sentOn", String(describing: p1), String(describing: p2), fallback: "File: %@, sent on %@") + } + } + internal enum Text { + /// %@, message received on %@ + internal static func receivedOn(_ p1: Any, _ p2: Any) -> String { + return L10n.tr("Localizable", "accessibility.text.receivedOn", String(describing: p1), String(describing: p2), fallback: "%@, message received on %@") + } + /// %@, message sent on %@ + internal static func sentOn(_ p1: Any, _ p2: Any) -> String { + return L10n.tr("Localizable", "accessibility.text.sentOn", String(describing: p1), String(describing: p2), fallback: "%@, message sent on %@") + } + } } internal enum Account { /// Account Status diff --git a/Ring/Ring/Extensions/Date+Helpers.swift b/Ring/Ring/Extensions/Date+Helpers.swift index 08a742511b9f0138c988e137aa54f71a37d9035a..241af497b9be6083fed4bcc69bfb777025ba3d31 100644 --- a/Ring/Ring/Extensions/Date+Helpers.swift +++ b/Ring/Ring/Extensions/Date+Helpers.swift @@ -77,4 +77,29 @@ extension Date { return dateString } + + func getTimeLabelString() -> String { + let currentDateTime = Date() + + // prepare formatter + let dateFormatter = DateFormatter() + + if Calendar.current.compare(currentDateTime, to: self, toGranularity: .day) == .orderedSame { + // age: [0, received the previous day[ + dateFormatter.dateFormat = "h:mma" + } else if Calendar.current.compare(currentDateTime, to: self, toGranularity: .weekOfYear) == .orderedSame { + // age: [received the previous day, received 7 days ago[ + dateFormatter.dateFormat = "E h:mma" + } else if Calendar.current.compare(currentDateTime, to: self, toGranularity: .year) == .orderedSame { + // age: [received 7 days ago, received the previous year[ + dateFormatter.dateFormat = "MMM d, h:mma" + } else { + // age: [received the previous year, inf[ + dateFormatter.dateFormat = "MMM d, yyyy h:mma" + } + + // generate the string containing the message time + return dateFormatter.string(from: self).uppercased() + } + } diff --git a/Ring/Ring/Features/Conversations/Conversation/ConversationViewController.swift b/Ring/Ring/Features/Conversations/Conversation/ConversationViewController.swift index 81ad3c4ebf8d0858d18a6ea53ec4bc3358394cf9..6bdb01559cca2c54c9a933c0f87bcc0c20cadfbc 100644 --- a/Ring/Ring/Features/Conversations/Conversation/ConversationViewController.swift +++ b/Ring/Ring/Features/Conversations/Conversation/ConversationViewController.swift @@ -469,6 +469,7 @@ class ConversationViewController: UIViewController, let profileImageView = UIImageView(frame: CGRect(x: 0, y: imageOffsetY, width: imageSize, height: imageSize)) profileImageView.frame = CGRect.init(x: 0, y: 0, width: imageSize, height: imageSize) profileImageView.center = CGPoint.init(x: imageSize / 2, y: titleView.center.y) + profileImageView.accessibilityElementsHidden = true if let profileName = displayName, !profileName.isEmpty { profileImageView.addSubview(AvatarView(profileImageData: profileImageData, username: profileName, size: 30)) @@ -542,6 +543,10 @@ class ConversationViewController: UIViewController, } private func setRightNavigationButtons() { + let contactName: String = !(viewModel.displayName.value?.isEmpty ?? true) + ? viewModel.displayName.value ?? "" + : viewModel.userName.value + // do not show call buttons for swarm with multiple participants if self.viewModel.conversation.getParticipants().count > 1 { self.navigationItem.rightBarButtonItems = [] @@ -555,6 +560,7 @@ class ConversationViewController: UIViewController, let audioCallItem = UIBarButtonItem() audioCallItem.image = UIImage(asset: Asset.callButton) + audioCallItem.accessibilityLabel = L10n.Accessibility.conversationStartVoiceCall(contactName) audioCallItem.rx.tap.throttle(Durations.halfSecond.toTimeInterval(), scheduler: MainScheduler.instance) .subscribe(onNext: { [weak self] in self?.placeAudioOnlyCall() @@ -563,6 +569,7 @@ class ConversationViewController: UIViewController, let videoCallItem = UIBarButtonItem() videoCallItem.image = UIImage(asset: Asset.videoRunning) + videoCallItem.accessibilityLabel = L10n.Accessibility.conversationStartVideoCall(contactName) videoCallItem.rx.tap.throttle(Durations.halfSecond.toTimeInterval(), scheduler: MainScheduler.instance) .subscribe(onNext: { [weak self] in self?.placeCall() diff --git a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/MessageContentVM.swift b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/MessageContentVM.swift index b809a89f8a87a7a77f42067910990a0b50828605..124047e88a907ea4351627aa3be025254c9bfbdc 100644 --- a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/MessageContentVM.swift +++ b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/MessageContentVM.swift @@ -106,6 +106,7 @@ enum ContextualMenuItem: Identifiable { class MessageContentVM: ObservableObject, PreviewViewControllerDelegate, PlayerDelegate, MessageAppearanceProtocol, NameObserver { @Published var content = "" + @Published var accessibilityLabelValue = "" @Published var metadata: LPLinkMetadata? // file transfer @@ -195,6 +196,7 @@ class MessageContentVM: ObservableObject, PreviewViewControllerDelegate, PlayerD maxImageSize = 100 } self.content = message.content + self.accessibilityLabelValue = message.accessibilityLabelValue self.transferStatus = message.transferStatus self.preferencesColor = preferencesColor self.updateMessageStyle() @@ -469,6 +471,7 @@ class MessageContentVM: ObservableObject, PreviewViewControllerDelegate, PlayerD self.content = self.message.content self.messageDeleted = self.message.isMessageDeleted() self.messageEdited = self.message.isMessageEdited() + self.accessibilityLabelValue = self.message.accessibilityLabelValue if self.messageDeleted || self.messageEdited { self.updateMessageStyle() } diff --git a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/MessageRowVM.swift b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/MessageRowVM.swift index dfa68791d4a382edba92fb5f2662e3ffc6021e9d..cba053d5efce69bc27a0b3861f8bdcdd5a1a17c2 100644 --- a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/MessageRowVM.swift +++ b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/MessageRowVM.swift @@ -46,7 +46,7 @@ class MessageRowVM: ObservableObject, MessageAppearanceProtocol, MessageReadObse var shouldShowTimeString = false { didSet { - self.timeString = self.shouldShowTimeString ? self.getTimeLabelString() : "" + self.timeString = self.shouldShowTimeString ? self.message.receivedDate.getTimeLabelString() : "" } } @@ -80,7 +80,7 @@ class MessageRowVM: ObservableObject, MessageAppearanceProtocol, MessageReadObse self.incoming = message.incoming self.centeredMessage = message.type.isContact || message.type == .initial self.readBorderColor = Color(UIColor.systemBackground) - self.timeString = getTimeLabelString() + self.timeString = self.message.receivedDate.getTimeLabelString() self.updateMessageStatus() } @@ -89,32 +89,6 @@ class MessageRowVM: ObservableObject, MessageAppearanceProtocol, MessageReadObse self.requestReadStatus(messageId: self.message.id) } - func getTimeLabelString() -> String { - let time = self.message.receivedDate - // get the current time - let currentDateTime = Date() - - // prepare formatter - let dateFormatter = DateFormatter() - - if Calendar.current.compare(currentDateTime, to: time, toGranularity: .day) == .orderedSame { - // age: [0, received the previous day[ - dateFormatter.dateFormat = "h:mma" - } else if Calendar.current.compare(currentDateTime, to: time, toGranularity: .weekOfYear) == .orderedSame { - // age: [received the previous day, received 7 days ago[ - dateFormatter.dateFormat = "E h:mma" - } else if Calendar.current.compare(currentDateTime, to: time, toGranularity: .year) == .orderedSame { - // age: [received 7 days ago, received the previous year[ - dateFormatter.dateFormat = "MMM d, h:mma" - } else { - // age: [received the previous year, inf[ - dateFormatter.dateFormat = "MMM d, yyyy h:mma" - } - - // generate the string containing the message time - return dateFormatter.string(from: time).uppercased() - } - func setSequencing(sequencing: MessageSequencing) { if self.sequencing != sequencing { DispatchQueue.main.async {[weak self] in diff --git a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/MessageBubbleView.swift b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/MessageBubbleView.swift index 7ff6d4e7cc8a62ddc56fa28361725830b06f2048..9232ebffae3cd56c41411f34342a1ab0ba2d26b9 100644 --- a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/MessageBubbleView.swift +++ b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/MessageBubbleView.swift @@ -63,6 +63,19 @@ struct MessageBubbleView: View { }) } ) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(model.accessibilityLabelValue)) + .conditionalModifier(AccessibilityActionModifier(actionName: ContextualMenuItem.preview.toString(), action: { model.contextMenuSelect(item: .preview) }), apply: model.menuItems.contains(.preview)) + .conditionalModifier(AccessibilityActionModifier(actionName: ContextualMenuItem.forward.toString(), action: { model.contextMenuSelect(item: .forward) }), apply: model.menuItems.contains(.forward)) + .conditionalModifier(AccessibilityActionModifier(actionName: ContextualMenuItem.share.toString(), action: { model.contextMenuSelect(item: .share) }), apply: model.menuItems.contains(.share)) + .conditionalModifier(AccessibilityActionModifier(actionName: ContextualMenuItem.save.toString(), action: { model.contextMenuSelect(item: .save) }), apply: model.menuItems.contains(.save)) + .conditionalModifier(AccessibilityActionModifier(actionName: ContextualMenuItem.copy.toString(), action: { model.contextMenuSelect(item: .copy) }), apply: model.menuItems.contains(.copy)) + .conditionalModifier(AccessibilityActionModifier(actionName: ContextualMenuItem.reply.toString(), action: { model.contextMenuSelect(item: .reply) }), apply: model.menuItems.contains(.reply)) + .conditionalModifier(AccessibilityActionModifier(actionName: ContextualMenuItem.deleteMessage.toString(), action: { model.contextMenuSelect(item: .deleteMessage) }), apply: model.menuItems.contains(.deleteMessage)) + .conditionalModifier(AccessibilityActionModifier(actionName: ContextualMenuItem.deleteFile.toString(), action: { model.contextMenuSelect(item: .deleteFile) }), apply: model.menuItems.contains(.deleteFile)) + .conditionalModifier(AccessibilityActionModifier(actionName: ContextualMenuItem.edit.toString(), action: { model.contextMenuSelect(item: .edit) }), apply: model.menuItems.contains(.edit)) + .accessibilityAddTraits(.isButton) + } private func renderCallMessage() -> some View { @@ -148,3 +161,15 @@ struct MessageBubbleWithEditionWrapper<Content: View>: View { } } } + +struct AccessibilityActionModifier: ViewModifier { + let actionName: String + let action: () -> Void + + func body(content: Content) -> some View { + content + .accessibilityAction(named: Text(actionName)) { + action() + } + } +} diff --git a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/MessagePanelView.swift b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/MessagePanelView.swift index f814619e7418799db2b8cc63f6734f4ac7b11d57..cc7c96cebe7a65d7c1718145f3c643c1eb84bb5b 100644 --- a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/MessagePanelView.swift +++ b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/MessagePanelView.swift @@ -163,18 +163,22 @@ struct MessagePanelView: View { Menu(content: menuContent, label: { MessagePanelImageButton(model: model, systemName: "plus.circle", width: defaultControlSize, height: defaultControlSize) }) + .accessibilityLabel(L10n.Accessibility.conversationShareMedia) + .accessibilityRemoveTraits(.isButton) Button(action: { self.model.sendPhoto() }, label: { MessagePanelImageButton(model: model, systemName: "camera", width: 44, height: defaultControlSize) }) + .accessibilityHint(L10n.Accessibility.conversationCameraHint) Spacer() .frame(width: 5) UITextViewWrapper(withBackground: true, text: $text, isFocused: $isFocused, dynamicHeight: $textHeight) .frame(minHeight: textHeight, maxHeight: textHeight) .cornerRadius(18) + .accessibilityLabel(L10n.Accessibility.conversationComposeMessage) .placeholder(when: text.isEmpty, alignment: .leading) { Text(model.placeholder) .padding(.horizontal, 12) @@ -184,6 +188,7 @@ struct MessagePanelView: View { .font(model.styling.textFont) .foregroundColor(model.styling.secondaryTextColor) .cornerRadius(18) + .accessibilityHidden(true) } Spacer() .frame(width: 10) diff --git a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/MessageSwiftUIViews.swift b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/MessageSwiftUIViews.swift index af2ad68b325327ce415bb209ea365187c9b746c3..2780adf82f4ff7ccc474362f49ca823ba49b35ef 100644 --- a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/MessageSwiftUIViews.swift +++ b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/MessageSwiftUIViews.swift @@ -66,6 +66,7 @@ struct PlayerSwiftUI: View { var withControls: Bool var customCornerRadius: CGFloat = 0 @Environment(\.colorScheme) var colorScheme + var body: some View { ZStack(alignment: .center) { if colorScheme == .dark { @@ -74,12 +75,16 @@ struct PlayerSwiftUI: View { .conditionalModifier(MessageCornerRadius(model: model), apply: customCornerRadius == 0) .conditionalCornerRadius(customCornerRadius, apply: customCornerRadius != 0) } - PlayerViewWrapper.init(viewModel: player, width: model.playerWidth * ratio, height: model.playerHeight * ratio, onLongGesture: onLongGesture, withControls: withControls) + PlayerViewWrapper(viewModel: player, width: model.playerWidth * ratio, height: model.playerHeight * ratio, onLongGesture: onLongGesture, withControls: withControls) .frame(height: model.playerHeight * ratio) .frame(width: model.playerWidth * ratio) .conditionalModifier(MessageCornerRadius(model: model), apply: customCornerRadius == 0) .conditionalCornerRadius(customCornerRadius, apply: customCornerRadius != 0) } + .accessibilityElement(children: .ignore) // Make it one element + .accessibilityLabel(player.pause.value ? L10n.Accessibility.audioPlayerPlay : L10n.Accessibility.audioPlayerPause) + .accessibilityAddTraits(.isButton) + .conditionalModifier(AccessibilityGestureModifier(action: player.toglePause), apply: UIAccessibility.isVoiceOverRunning) } } @@ -137,3 +142,13 @@ struct MediaView: View { } } } + +struct AccessibilityGestureModifier: ViewModifier { + var action: () -> Void + + func body(content: Content) -> some View { + content.onTapGesture { + action() // Call the action when the gesture is detected + } + } +} diff --git a/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Views/ConversationsView.swift b/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Views/ConversationsView.swift index 2bd0d6f682c18054adb06a28c890903ab8043371..371e80908ef705268d6eb83335c3ff8629664740 100644 --- a/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Views/ConversationsView.swift +++ b/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Views/ConversationsView.swift @@ -217,6 +217,9 @@ struct ConversationRowView: View { } } .transition(.opacity) + .accessibilityElement(children: /*@START_MENU_TOKEN@*/.ignore/*@END_MENU_TOKEN@*/) + .accessibilityLabel(constreuctChatRowAccessibilityLabel()) + } private var presenceIndicator: some View { @@ -241,4 +244,32 @@ struct ConversationRowView: View { ) .offset(x: -1, y: -1) } + + private func constreuctChatRowAccessibilityLabel() -> String { + var label = "\(model.name)" + + if model.unreadMessages > 0 { + label += ". " + L10n.Accessibility.conversationRowUnreadCount(model.unreadMessages) + } + + if model.swiftUIModel.isBlocked { + label += ". " + L10n.Accessibility.conversationRowBlocked + } else if model.swiftUIModel.isSyncing { + label += ". " + L10n.Accessibility.conversationRowSyncing + } else if !model.lastMessage.isEmpty { + label += ". " + L10n.Accessibility.conversationRowLastMessage(model.lastMessageDate) + ": \(model.lastMessage)" + } + + switch model.presence { + case .connected: + label += ". " + L10n.Accessibility.userPresenceOnline + case .available: + label += ". " + L10n.Accessibility.userPresenceAvailable + default: + break + } + + return label + } + } diff --git a/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Views/RequestsView.swift b/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Views/RequestsView.swift index 360b87bd3a1bbe1ccc2825f9734789ebe3a7c99e..cdac7a5268ff6062beeac00ea34206b7dd8907ca 100644 --- a/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Views/RequestsView.swift +++ b/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Views/RequestsView.swift @@ -43,6 +43,9 @@ struct RequestsIndicatorView: View { .frame(maxWidth: .infinity) .background(Color.jamiRequestsColor) .cornerRadius(cornerRadius) + .accessibilityElement(children: /*@START_MENU_TOKEN@*/.ignore/*@END_MENU_TOKEN@*/) + .accessibilityLabel(L10n.Accessibility.pendingRequestsRow(model.unreadRequests)) + .accessibilityHint(L10n.Accessibility.pendingRequestsRowHint) } private var icon: some View { @@ -82,14 +85,27 @@ struct RequestsIndicatorView: View { struct RequestsView: View { @ObservedObject var model: RequestsViewModel + @Environment(\.presentationMode) + var presentation var body: some View { NavigationView { VStack(spacing: 0) { - Text(model.title) - .font(.headline) - .foregroundColor(Color(UIColor.systemBackground)) - .padding(.vertical, 20) + ZStack { + HStack { + Spacer() // Push the close button to the right + + CloseButton( + action: { presentation.wrappedValue.dismiss() }, + accessibilityIdentifier: SmartListAccessibilityIdentifiers.requestsCloseButton + ) + } + + Text(model.title) + .font(.headline) + .foregroundColor(Color(UIColor.systemBackground)) + .frame(maxWidth: .infinity, alignment: .center) // This will center the image horizontally + } requestsList } @@ -206,6 +222,7 @@ struct RequestsRowView: View { .padding(.horizontal, buttonPadding) .foregroundColor(requestRow.status.color()) } + .accessibilityElement(children: .combine) } private var avatarView: some View { @@ -222,21 +239,21 @@ struct RequestsRowView: View { private var actionButtonsView: some View { HStack { - actionIcon("slash.circle") { + actionIcon("slash.circle", L10n.Accessibility.pendingRequestsListBlockUser) { listModel.block(requestRow: requestRow) } Spacer().frame(width: spacerWidth) - actionIcon("xmark") { + actionIcon("xmark", L10n.Accessibility.pendingRequestsListRejectInvitation) { listModel.discard(requestRow: requestRow) } Spacer().frame(width: spacerWidth) - actionIcon("checkmark") { + actionIcon("checkmark", L10n.Accessibility.pendingRequestsListAcceptInvitation) { listModel.accept(requestRow: requestRow) } } } - private func actionIcon(_ systemName: String, action: @escaping () -> Void) -> some View { + private func actionIcon(_ systemName: String, _ accessibilityLabelValue: String, action: @escaping () -> Void) -> some View { Image(systemName: systemName) .resizable() .aspectRatio(contentMode: .fit) @@ -248,5 +265,8 @@ struct RequestsRowView: View { .background(Color.requestsBadgeBackground) .cornerRadius(cornerRadius) .onTapGesture(perform: action) + .accessibilityLabel(accessibilityLabelValue) + .accessibilityRemoveTraits(.isImage) + .accessibilityAddTraits(.isButton) } } diff --git a/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Views/SmartListContentView.swift b/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Views/SmartListContentView.swift index e03554979d67c26d26fa85b158a46d6cb02ac044..5ad5b72c1a3f90d8d78e7afdb30d902d4104b335 100644 --- a/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Views/SmartListContentView.swift +++ b/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Views/SmartListContentView.swift @@ -195,6 +195,8 @@ struct SmartListContentView: View { .background(Color.jamiTertiaryControl) .cornerRadius(12) .onTapGesture(perform: action) + .accessibilityElement(children: .ignore) + .accessibilityLabel(title) } @ViewBuilder private var conversationsSearchHeaderView: some View { diff --git a/Ring/Ring/Features/Settings/Me/LinkDeviceView.swift b/Ring/Ring/Features/Settings/Me/LinkDeviceView.swift index 69d47fc1c9cb2bcfb69b8ca8615c42c25428492f..62fd519ba1fde17dd9c02c7071fca25474f522cf 100644 --- a/Ring/Ring/Features/Settings/Me/LinkDeviceView.swift +++ b/Ring/Ring/Features/Settings/Me/LinkDeviceView.swift @@ -96,7 +96,7 @@ struct LinkDeviceView: View { .resizable() .aspectRatio(contentMode: .fit) .frame(width: 140, height: 140) - .accessibilityHidden(/*@START_MENU_TOKEN@*/true/*@END_MENU_TOKEN@*/) + .accessibilityHidden(true) } HStack(spacing: 15) { Image(systemName: "info.circle") diff --git a/Ring/Ring/Features/Settings/Me/LinkedDevicesView.swift b/Ring/Ring/Features/Settings/Me/LinkedDevicesView.swift index 7a28da29d137596a79a16438cec5fe963ffefdde..1b9bab2c14500d60d1822b6ef332c0390dc4d3a7 100644 --- a/Ring/Ring/Features/Settings/Me/LinkedDevicesView.swift +++ b/Ring/Ring/Features/Settings/Me/LinkedDevicesView.swift @@ -96,7 +96,7 @@ struct LinkedDevicesView: View { .font(.footnote) .foregroundColor(Color(UIColor.secondaryLabel)) .lineLimit(1) - .accessibilityHidden(/*@START_MENU_TOKEN@*/true/*@END_MENU_TOKEN@*/) + .accessibilityHidden(true) .conditionalTextSelection() } Spacer() diff --git a/Ring/Ring/Models/MessageModel.swift b/Ring/Ring/Models/MessageModel.swift index 950e6025573d9a316a2d4363b37b860497d44388..4ebab40989e552d085fc5e8e39cb8a07c79a7d05 100644 --- a/Ring/Ring/Models/MessageModel.swift +++ b/Ring/Ring/Models/MessageModel.swift @@ -173,6 +173,7 @@ public class MessageModel { var reactions = Set<MessageAction>() var editions = Set<MessageAction>() var statusForParticipant = [String: MessageStatus]() + var accessibilityLabelValue: String = "" init(withId id: String, receivedDate: Date, content: String, authorURI: String, incoming: Bool) { self.daemonId = id @@ -212,6 +213,7 @@ public class MessageModel { } } } + self.updateAccessibilityLabel(for: self.type, receivedDate: self.receivedDate) } // swiftlint:disable:next cyclomatic_complexity @@ -292,6 +294,8 @@ public class MessageModel { default: break } + + self.updateAccessibilityLabel(for: self.type, receivedDate: receivedDate) } func getContactInteractionString(name: String) -> String? { @@ -315,6 +319,8 @@ public class MessageModel { }) { self.parents.append(contentsOf: parents) } + + self.updateAccessibilityLabel(for: self.type, receivedDate: receivedDate) } func isReply() -> Bool { @@ -388,4 +394,65 @@ public class MessageModel { func reactionsMessageIdsBySender(jamiId: String) -> [String] { return Array(self.reactions.filter({ item in item.author == jamiId }).map({ item in item.id })) } + + private func updateAccessibilityLabel(for messageType: MessageType, receivedDate: Date) { + + let timestamp = String(receivedDate.getTimeLabelString()) + + switch messageType { + case .text: + self.accessibilityLabelValue = self.content.isEmpty + ? L10n.Accessibility.textNotAvailable(timestamp) + : (self.incoming + ? L10n.Accessibility.Text.receivedOn(self.content, timestamp) + : L10n.Accessibility.Text.sentOn(self.content, timestamp)) + + case .call: + self.accessibilityLabelValue = self.content.replacingOccurrences(of: " - ", with: "." + L10n.Accessibility.Call.lasted + " ") + + case .fileTransfer: + self.accessibilityLabelValue = self.content.isEmpty + ? L10n.Accessibility.fileTransferNoName(timestamp) + : (self.incoming + ? L10n.Accessibility.FileTransfer.receivedOn(self.content, timestamp) + : L10n.Accessibility.FileTransfer.sentOn(self.content, timestamp)) + + default: + return + } + + // TODO: add reply target + if self.isReply() { + self.accessibilityLabelValue += ". " + L10n.Accessibility.inReply + } + + updateReadStatusAccessibilityLabel() + updateEditedStatusAccessibilityLabel() + updateDeletedStatusAccessibilityLabel() + } + + private func updateReadStatusAccessibilityLabel() { + if !self.incoming { + switch self.status { + case .displayed: + self.accessibilityLabelValue += ". " + L10n.Accessibility.messageBubbleRead + case .sent: + self.accessibilityLabelValue += L10n.Accessibility.messageBubbleUnread + default: + break + } + } + } + + private func updateEditedStatusAccessibilityLabel() { + if self.isMessageEdited() { + self.accessibilityLabelValue += ". " + L10n.Accessibility.messageBubbleEdited + } + } + + private func updateDeletedStatusAccessibilityLabel() { + if self.isMessageDeleted() { + self.accessibilityLabelValue = L10n.Accessibility.messageBubbleDeleted + } + } } diff --git a/Ring/Ring/Resources/en.lproj/Localizable.strings b/Ring/Ring/Resources/en.lproj/Localizable.strings index b14d2a9d0bd59ac95182112e96742b3f3f65da97..1de6d15c79a7b77731699554d1485c7be5b96371 100644 --- a/Ring/Ring/Resources/en.lproj/Localizable.strings +++ b/Ring/Ring/Resources/en.lproj/Localizable.strings @@ -521,5 +521,33 @@ "accessibility.accountSummaryQrCodeHint" = "Double-tap to view your account QR code"; "accessibility.accountSummaryEditProfileHint" = "Double-tap to edit your profile"; "accessibility.accountSummaryEditSettingsButton" = "Settings"; -"accessibility.switchButtonIsOn" = "On"; -"accessibility.switchButtonIsOff" = "Off"; +"accessibility.conversationStartVoiceCall" = "Start a voice call with %@"; +"accessibility.conversationStartVideoCall" = "Start a video call with %@"; +"accessibility.conversationComposeMessage" = "Compose a message"; +"accessibility.conversationShareMedia" = "Share media"; +"accessibility.conversationCameraHint" = "Double-tap to open camera"; +"accessibility.audioPlayerPlay" = "Play"; +"accessibility.audioPlayerPause" = "Pause"; +"accessibility.conversationRowUnreadCount" = "%@ unread messages"; +"accessibility.conversationRowBlocked" = "Conversation blocked"; +"accessibility.conversationRowSyncing" = "Syncing in progress"; +"accessibility.conversationRowLastMessage" = "Last message on %@"; +"accessibility.userPresenceOnline" = "Online"; +"accessibility.userPresenceAvailable" = "Available"; +"accessibility.pendingRequestsRow" = "Invitation received: %@ pending invitation"; +"accessibility.pendingRequestsRowHint" = "Double-tap to review and reply to invitations you received"; +"accessibility.pendingRequestsListBlockUser" = "Block invitation sender"; +"accessibility.pendingRequestsListRejectInvitation" = "Reject invitation"; +"accessibility.pendingRequestsListAcceptInvitation" = "Accept invitation"; +"accessibility.messageBubbleDeleted" = "Message deleted"; +"accessibility.messageBubbleEdited" = "Edited"; +"accessibility.messageBubbleRead" = "Read"; +"accessibility.messageBubbleUnread" = "Unread"; +"accessibility.textNotAvailable" = "Text message received on %@, content not available"; +"accessibility.fileTransferNoName" = "File received on %@, name not available"; +"accessibility.inReply" = "In reply to a message"; +"accessibility.text.receivedOn" = "%@, message received on %@"; +"accessibility.text.sentOn" = "%@, message sent on %@"; +"accessibility.fileTransfer.receivedOn" = "File: %@ , received on %@"; +"accessibility.fileTransfer.sentOn" = "File: %@, sent on %@"; +"accessibility.call.lasted" = "Lasted"; diff --git a/Ring/Ring/SwarmCreation/SwarmProfile.swift b/Ring/Ring/SwarmCreation/SwarmProfile.swift index e6e597252f25f77cdbd618e0b38d51353aff503c..ea20203dbbcbad00fb131c18f286cfaa6864699d 100644 --- a/Ring/Ring/SwarmCreation/SwarmProfile.swift +++ b/Ring/Ring/SwarmCreation/SwarmProfile.swift @@ -82,6 +82,8 @@ struct SwarmProfile: View { Spacer() VStack { imagePickerButton() + .accessibilityLabel(L10n.Accessibility.swarmPicturePicker) + .accessibilityHint(L10n.Accessibility.swarmPicturePickerHint) VStack(alignment: .center) { TextField(L10n.Swarm.namePlaceholder, text: $model.swarmName) .disableAutocorrection(true)