feat: add notification badge in AccountList scene

This commit is contained in:
CMK 2021-09-16 16:30:21 +08:00
parent 7dea01da1e
commit 5a746ef881
18 changed files with 302 additions and 42 deletions

View File

@ -6,7 +6,39 @@
//
import UIKit
import CryptoKit
extension UserDefaults {
public static let shared = UserDefaults(suiteName: AppName.groupID)!
}
extension UserDefaults {
// always use hash value (SHA256) from accessToken as key
private static func deriveKey(from accessToken: String, prefix: String) -> String {
let digest = SHA256.hash(data: Data(accessToken.utf8))
let bytes = [UInt8](digest)
let hex = bytes.toHexString()
let key = prefix + "@" + hex
return key
}
private static let notificationCountKeyPrefix = "notification_count"
public func getNotificationCountWithAccessToken(accessToken: String) -> Int {
let prefix = UserDefaults.notificationCountKeyPrefix
let key = UserDefaults.deriveKey(from: accessToken, prefix: prefix)
return integer(forKey: key)
}
public func setNotificationCountWithAccessToken(accessToken: String, value: Int) {
let prefix = UserDefaults.notificationCountKeyPrefix
let key = UserDefaults.deriveKey(from: accessToken, prefix: prefix)
setValue(value, forKey: key)
}
public func increaseNotificationCount(accessToken: String) {
let count = getNotificationCountWithAccessToken(accessToken: accessToken)
setNotificationCountWithAccessToken(accessToken: accessToken, value: count + 1)
}
}

View File

@ -541,7 +541,8 @@
"add_account": "Add Account",
},
"wizard": {
"multiple_account_switch_intro_description": "Switch between multiple accounts by holding the profile button."
"multiple_account_switch_intro_description": "Switch between multiple accounts by holding the profile button.",
"accessibility_hint": "Double tap to dismiss this wizard"
}
}
}

View File

@ -265,6 +265,8 @@
DB4924E226312AB200E9DB22 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4924E126312AB200E9DB22 /* NotificationService.swift */; };
DB4932B126F1FB5300EF46D4 /* WizardCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4932B026F1FB5300EF46D4 /* WizardCardView.swift */; };
DB4932B326F2054200EF46D4 /* CircleAvatarButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4932B226F2054200EF46D4 /* CircleAvatarButton.swift */; };
DB4932B726F30F0700EF46D4 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20223826146553000C64BF /* Array.swift */; };
DB4932B926F31AD300EF46D4 /* BadgeButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4932B826F31AD300EF46D4 /* BadgeButton.swift */; };
DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A61325FF2C5600B98345 /* EmojiService.swift */; };
DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */; };
DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */; };
@ -1024,6 +1026,7 @@
DB4924E126312AB200E9DB22 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
DB4932B026F1FB5300EF46D4 /* WizardCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WizardCardView.swift; sourceTree = "<group>"; };
DB4932B226F2054200EF46D4 /* CircleAvatarButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircleAvatarButton.swift; sourceTree = "<group>"; };
DB4932B826F31AD300EF46D4 /* BadgeButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeButton.swift; sourceTree = "<group>"; };
DB49A61325FF2C5600B98345 /* EmojiService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiService.swift; sourceTree = "<group>"; };
DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel.swift"; sourceTree = "<group>"; };
DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel+LoadState.swift"; sourceTree = "<group>"; };
@ -2817,6 +2820,7 @@
isa = PBXGroup;
children = (
DBA5A53026F08EF000CACBAA /* DragIndicatorView.swift */,
DB4932B826F31AD300EF46D4 /* BadgeButton.swift */,
);
path = View;
sourceTree = "<group>";
@ -4155,6 +4159,7 @@
2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */,
DB482A4B261340A7008AE74C /* APIService+UserTimeline.swift in Sources */,
DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */,
DB4932B926F31AD300EF46D4 /* BadgeButton.swift in Sources */,
0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */,
DBCC3B30261440A50045B23D /* UITabBarController.swift in Sources */,
DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */,
@ -4236,6 +4241,7 @@
buildActionMask = 2147483647;
files = (
DB6804D12637CE4700430867 /* UserDefaults.swift in Sources */,
DB4932B726F30F0700EF46D4 /* Array.swift in Sources */,
DB6804922637CD8700430867 /* AppName.swift in Sources */,
DB6804FD2637CFEC00430867 /* AppSecret.swift in Sources */,
);

View File

@ -70,6 +70,7 @@ internal enum Asset {
internal static let valid = ColorAsset(name: "Colors/TextField/valid")
}
internal static let alertYellow = ColorAsset(name: "Colors/alert.yellow")
internal static let badgeBackground = ColorAsset(name: "Colors/badge.background")
internal static let battleshipGrey = ColorAsset(name: "Colors/battleshipGrey")
internal static let brandBlue = ColorAsset(name: "Colors/brand.blue")
internal static let brandBlueDarken20 = ColorAsset(name: "Colors/brand.blue.darken.20")

View File

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "48",
"green" : "59",
"red" : "255"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -1,20 +1,20 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
},
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"red" : "0.875",
"blue" : "0.353",
"green" : "0.251"
"blue" : "90",
"green" : "64",
"red" : "223"
}
},
"idiom" : "universal"
}
]
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -86,11 +86,10 @@ extension AccountListViewModel {
switch item {
case .authentication(let objectID):
let authentication = managedObjectContext.object(with: objectID) as! MastodonAuthentication
let user = authentication.user
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: AccountListTableViewCell.self), for: indexPath) as! AccountListTableViewCell
AccountListViewModel.configure(
cell: cell,
user: user,
authentication: authentication,
activeMastodonUserObjectID: self.activeMastodonUserObjectID.eraseToAnyPublisher()
)
return cell
@ -107,9 +106,11 @@ extension AccountListViewModel {
static func configure(
cell: AccountListTableViewCell,
user: MastodonUser,
authentication: MastodonAuthentication,
activeMastodonUserObjectID: AnyPublisher<NSManagedObjectID?, Never>
) {
let user = authentication.user
// avatar
cell.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: user.avatarImageURL()))
@ -127,14 +128,32 @@ extension AccountListViewModel {
let usernameMetaContent = PlaintextMetaContent(string: "@" + user.acctWithDomain)
cell.usernameLabel.configure(content: usernameMetaContent)
// badge
let accessToken = authentication.userAccessToken
let count = UserDefaults.shared.getNotificationCountWithAccessToken(accessToken: accessToken)
cell.badgeButton.setBadge(number: count)
// checkmark
activeMastodonUserObjectID
.receive(on: DispatchQueue.main)
.sink { objectID in
let isCurrentUser = user.objectID == objectID
cell.tintColor = .label
cell.accessoryType = isCurrentUser ? .checkmark : .none
cell.checkmarkImageView.isHidden = !isCurrentUser
if isCurrentUser {
cell.accessibilityTraits.insert(.selected)
} else {
cell.accessibilityTraits.remove(.selected)
}
}
.store(in: &cell.disposeBag)
cell.accessibilityLabel = [
cell.nameLabel.text,
cell.usernameLabel.text,
cell.badgeButton.accessibilityLabel
]
.compactMap { $0 }
.joined(separator: " ")
}
}

View File

@ -17,6 +17,13 @@ final class AccountListTableViewCell: UITableViewCell {
let avatarButton = CircleAvatarButton(frame: .zero)
let nameLabel = MetaLabel(style: .accountListName)
let usernameLabel = MetaLabel(style: .accountListUsername)
let badgeButton = BadgeButton()
let checkmarkImageView: UIImageView = {
let image = UIImage(systemName: "checkmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 17, weight: .semibold))
let imageView = UIImageView(image: image)
imageView.tintColor = .label
return imageView
}()
let separatorLine = UIView.separatorLine
override func prepareForReuse() {
@ -63,15 +70,36 @@ extension AccountListTableViewCell {
labelContainerStackView.leadingAnchor.constraint(equalTo: avatarButton.trailingAnchor, constant: 10),
contentView.bottomAnchor.constraint(equalTo: labelContainerStackView.bottomAnchor, constant: 10),
avatarButton.heightAnchor.constraint(equalTo: labelContainerStackView.heightAnchor, multiplier: 0.8).priority(.required - 10),
labelContainerStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
])
labelContainerStackView.addArrangedSubview(nameLabel)
labelContainerStackView.addArrangedSubview(usernameLabel)
badgeButton.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(badgeButton)
NSLayoutConstraint.activate([
badgeButton.leadingAnchor.constraint(equalTo: labelContainerStackView.trailingAnchor, constant: 4),
badgeButton.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
badgeButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 16).priority(.required - 1),
badgeButton.widthAnchor.constraint(equalTo: badgeButton.heightAnchor, multiplier: 1.0).priority(.required - 1),
])
badgeButton.setContentHuggingPriority(.required - 10, for: .horizontal)
badgeButton.setContentCompressionResistancePriority(.required - 10, for: .horizontal)
checkmarkImageView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(checkmarkImageView)
NSLayoutConstraint.activate([
checkmarkImageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
checkmarkImageView.leadingAnchor.constraint(equalTo: badgeButton.trailingAnchor, constant: 12),
checkmarkImageView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor),
])
checkmarkImageView.setContentHuggingPriority(.required - 9, for: .horizontal)
checkmarkImageView.setContentCompressionResistancePriority(.required - 9, for: .horizontal)
avatarButton.isUserInteractionEnabled = false
nameLabel.isUserInteractionEnabled = false
usernameLabel.isUserInteractionEnabled = false
badgeButton.isUserInteractionEnabled = false
separatorLine.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(separatorLine)

View File

@ -0,0 +1,46 @@
//
// BadgeButton.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021-9-16.
//
import UIKit
final class BadgeButton: UIButton {
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension BadgeButton {
private func _init() {
titleLabel?.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .systemFont(ofSize: 13, weight: .medium))
setBackgroundColor(Asset.Colors.badgeBackground.color, for: .normal)
setTitleColor(.white, for: .normal)
contentEdgeInsets = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5)
}
override func layoutSubviews() {
super.layoutSubviews()
layer.masksToBounds = true
layer.cornerRadius = frame.height * 0.5
}
func setBadge(number: Int) {
let number = min(99, max(0, number))
setTitle("\(number)", for: .normal)
self.isHidden = number == 0
accessibilityLabel = "\(number) unread notification"
}
}

View File

@ -26,14 +26,7 @@ extension HomeTimelineViewController {
showMenu,
moveMenu,
dropMenu,
UIAction(title: "Toggle EmptyView", image: UIImage(systemName: "clear"), attributes: []) { [weak self] action in
guard let self = self else { return }
if self.emptyView.superview != nil {
self.emptyView.removeFromSuperview()
} else {
self.showEmptyView()
}
},
miscMenu,
UIAction(title: "Settings", image: UIImage(systemName: "gear"), attributes: []) { [weak self] action in
guard let self = self else { return }
self.showSettings(action)
@ -139,6 +132,39 @@ extension HomeTimelineViewController {
}
)
}
var miscMenu: UIMenu {
return UIMenu(
title: "Debug…",
image: UIImage(systemName: "switch.2"),
identifier: nil,
options: [],
children: [
UIAction(title: "Toggle EmptyView", image: UIImage(systemName: "clear"), attributes: []) { [weak self] action in
guard let self = self else { return }
if self.emptyView.superview != nil {
self.emptyView.removeFromSuperview()
} else {
self.showEmptyView()
}
},
UIAction(
title: "notification badge +1",
image: UIImage(systemName: "1.circle.fill"),
identifier: nil,
attributes: [],
state: .off,
handler: { [weak self] _ in
guard let self = self else { return }
guard let accessToken = self.context.authenticationService.activeMastodonAuthentication.value?.userAccessToken else { return }
UserDefaults.shared.increaseNotificationCount(accessToken: accessToken)
self.context.notificationService.applicationIconBadgeNeedsUpdate.send()
}
)
]
)
}
}
extension HomeTimelineViewController {

View File

@ -194,12 +194,20 @@ extension MainTabBarController {
.store(in: &disposeBag)
// handle push notification. toggle entry when finish fetch latest notification
context.notificationService.hasUnreadPushNotification
Publishers.CombineLatest(
context.authenticationService.activeMastodonAuthentication,
context.notificationService.unreadNotificationCountDidUpdate
)
.receive(on: DispatchQueue.main)
.sink { [weak self] hasUnreadPushNotification in
.sink { [weak self] authentication, _ in
guard let self = self else { return }
guard let notificationViewController = self.notificationViewController else { return }
let hasUnreadPushNotification: Bool = authentication.flatMap { authentication in
let count = UserDefaults.shared.getNotificationCountWithAccessToken(accessToken: authentication.userAccessToken)
return count > 0
} ?? false
let image = hasUnreadPushNotification ? UIImage(systemName: "bell.badge.fill")! : UIImage(systemName: "bell.fill")!
notificationViewController.tabBarItem.image = image
notificationViewController.navigationController?.tabBarItem.image = image

View File

@ -166,6 +166,16 @@ extension NotificationViewController {
self.viewModel.loadLatestStateMachine.enter(NotificationViewModel.LoadLatestState.Loading.self)
}
}
// reset notification count
context.notificationService.clearNotificationCountForActiveUser()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
// reset notification count
context.notificationService.clearNotificationCountForActiveUser()
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {

View File

@ -67,8 +67,6 @@ extension NotificationViewModel.LoadLatestState {
viewModel.isFetchingLatestNotification.value = false
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch notification failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
case .finished:
// toggle unread state
viewModel.context.notificationService.hasUnreadPushNotification.value = false
// handle isFetchingLatestTimeline in fetch controller delegate
break
}

View File

@ -107,4 +107,28 @@ extension WizardCardView {
backgroundShapeLayer.fillColor = UIColor.white.cgColor
backgroundShapeLayer.path = path.cgPath
}
override var isAccessibilityElement: Bool {
get { true }
set { }
}
override var accessibilityLabel: String? {
get {
return [
titleLabel.text,
descriptionLabel.text
]
.compactMap { $0 }
.joined(separator: " ")
}
set { }
}
override var accessibilityHint: String? {
get {
return "Wizard for account switcher on the Profile tab. Double tap to dismiss this wizard"
}
set { }
}
}

View File

@ -24,11 +24,12 @@ final class NotificationService {
weak var authenticationService: AuthenticationService?
let isNotificationPermissionGranted = CurrentValueSubject<Bool, Never>(false)
let deviceToken = CurrentValueSubject<Data?, Never>(nil)
let applicationIconBadgeNeedsUpdate = CurrentValueSubject<Void, Never>(Void())
// output
/// [Token: UserID]
let notificationSubscriptionDict: [String: NotificationViewModel] = [:]
let hasUnreadPushNotification = CurrentValueSubject<Bool, Never>(false)
let unreadNotificationCountDidUpdate = CurrentValueSubject<Void, Never>(Void())
let requestRevealNotificationPublisher = PassthroughSubject<Mastodon.Entity.Notification.ID, Never>()
init(
@ -57,6 +58,26 @@ final class NotificationService {
os_log(.info, log: .api, "%{public}s[%{public}ld], %{public}s: deviceToken: %s", ((#file as NSString).lastPathComponent), #line, #function, token)
}
.store(in: &disposeBag)
Publishers.CombineLatest(
authenticationService.mastodonAuthentications,
applicationIconBadgeNeedsUpdate
)
.receive(on: DispatchQueue.main)
.sink { [weak self] mastodonAuthentications, _ in
guard let self = self else { return }
var count = 0
for authentication in mastodonAuthentications {
count += UserDefaults.shared.getNotificationCountWithAccessToken(accessToken: authentication.userAccessToken)
}
UserDefaults.shared.notificationBadgeCount = count
UIApplication.shared.applicationIconBadgeNumber = count
self.unreadNotificationCountDidUpdate.send()
}
.store(in: &disposeBag)
}
}
@ -101,7 +122,9 @@ extension NotificationService {
}
func handle(mastodonPushNotification: MastodonPushNotification) {
hasUnreadPushNotification.value = true
defer {
unreadNotificationCountDidUpdate.send()
}
// Subscription maybe failed to cancel when sign-out
// Try cancel again if receive that kind push notification
@ -154,6 +177,17 @@ extension NotificationService {
}
extension NotificationService {
func clearNotificationCountForActiveUser() {
guard let authenticationService = self.authenticationService else { return }
if let accessToken = authenticationService.activeMastodonAuthentication.value?.userAccessToken {
UserDefaults.shared.setNotificationCountWithAccessToken(accessToken: accessToken, value: 0)
}
applicationIconBadgeNeedsUpdate.send()
}
}
// MARK: - NotificationViewModel
extension NotificationService {

View File

@ -100,6 +100,11 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
let notificationID = String(mastodonPushNotification.notificationID)
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] notification %s", ((#file as NSString).lastPathComponent), #line, #function, notificationID)
let accessToken = mastodonPushNotification.accessToken
UserDefaults.shared.increaseNotificationCount(accessToken: accessToken)
appContext.notificationService.applicationIconBadgeNeedsUpdate.send()
appContext.notificationService.handle(mastodonPushNotification: mastodonPushNotification)
completionHandler([.sound])
}

View File

@ -87,9 +87,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// Called when the scene has moved from an inactive state to an active state.
// Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
// reset notification badge
UserDefaults.shared.notificationBadgeCount = 0
UIApplication.shared.applicationIconBadgeNumber = 0
// update application badge
AppContext.shared.notificationService.applicationIconBadgeNeedsUpdate.send()
// trigger status filter update
AppContext.shared.statusFilterService.filterUpdatePublisher.send()

View File

@ -60,6 +60,9 @@ class NotificationService: UNNotificationServiceExtension {
bestAttemptContent.sound = UNNotificationSound.init(named: UNNotificationSoundName(rawValue: "BoopSound.caf"))
bestAttemptContent.userInfo["plaintext"] = plaintextData
let accessToken = notification.accessToken
UserDefaults.shared.increaseNotificationCount(accessToken: accessToken)
UserDefaults.shared.notificationBadgeCount += 1
bestAttemptContent.badge = NSNumber(integerLiteral: UserDefaults.shared.notificationBadgeCount)