feat: add notification badge in AccountList scene
This commit is contained in:
parent
7dea01da1e
commit
5a746ef881
|
@ -6,7 +6,39 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
extension UserDefaults {
|
extension UserDefaults {
|
||||||
public static let shared = UserDefaults(suiteName: AppName.groupID)!
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
@ -541,7 +541,8 @@
|
||||||
"add_account": "Add Account",
|
"add_account": "Add Account",
|
||||||
},
|
},
|
||||||
"wizard": {
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -265,6 +265,8 @@
|
||||||
DB4924E226312AB200E9DB22 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4924E126312AB200E9DB22 /* NotificationService.swift */; };
|
DB4924E226312AB200E9DB22 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4924E126312AB200E9DB22 /* NotificationService.swift */; };
|
||||||
DB4932B126F1FB5300EF46D4 /* WizardCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4932B026F1FB5300EF46D4 /* WizardCardView.swift */; };
|
DB4932B126F1FB5300EF46D4 /* WizardCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4932B026F1FB5300EF46D4 /* WizardCardView.swift */; };
|
||||||
DB4932B326F2054200EF46D4 /* CircleAvatarButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4932B226F2054200EF46D4 /* CircleAvatarButton.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 */; };
|
DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A61325FF2C5600B98345 /* EmojiService.swift */; };
|
||||||
DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.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 */; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel+LoadState.swift"; sourceTree = "<group>"; };
|
||||||
|
@ -2817,6 +2820,7 @@
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
DBA5A53026F08EF000CACBAA /* DragIndicatorView.swift */,
|
DBA5A53026F08EF000CACBAA /* DragIndicatorView.swift */,
|
||||||
|
DB4932B826F31AD300EF46D4 /* BadgeButton.swift */,
|
||||||
);
|
);
|
||||||
path = View;
|
path = View;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -4155,6 +4159,7 @@
|
||||||
2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */,
|
2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */,
|
||||||
DB482A4B261340A7008AE74C /* APIService+UserTimeline.swift in Sources */,
|
DB482A4B261340A7008AE74C /* APIService+UserTimeline.swift in Sources */,
|
||||||
DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */,
|
DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */,
|
||||||
|
DB4932B926F31AD300EF46D4 /* BadgeButton.swift in Sources */,
|
||||||
0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */,
|
0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */,
|
||||||
DBCC3B30261440A50045B23D /* UITabBarController.swift in Sources */,
|
DBCC3B30261440A50045B23D /* UITabBarController.swift in Sources */,
|
||||||
DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */,
|
DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */,
|
||||||
|
@ -4236,6 +4241,7 @@
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
DB6804D12637CE4700430867 /* UserDefaults.swift in Sources */,
|
DB6804D12637CE4700430867 /* UserDefaults.swift in Sources */,
|
||||||
|
DB4932B726F30F0700EF46D4 /* Array.swift in Sources */,
|
||||||
DB6804922637CD8700430867 /* AppName.swift in Sources */,
|
DB6804922637CD8700430867 /* AppName.swift in Sources */,
|
||||||
DB6804FD2637CFEC00430867 /* AppSecret.swift in Sources */,
|
DB6804FD2637CFEC00430867 /* AppSecret.swift in Sources */,
|
||||||
);
|
);
|
||||||
|
|
|
@ -70,6 +70,7 @@ internal enum Asset {
|
||||||
internal static let valid = ColorAsset(name: "Colors/TextField/valid")
|
internal static let valid = ColorAsset(name: "Colors/TextField/valid")
|
||||||
}
|
}
|
||||||
internal static let alertYellow = ColorAsset(name: "Colors/alert.yellow")
|
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 battleshipGrey = ColorAsset(name: "Colors/battleshipGrey")
|
||||||
internal static let brandBlue = ColorAsset(name: "Colors/brand.blue")
|
internal static let brandBlue = ColorAsset(name: "Colors/brand.blue")
|
||||||
internal static let brandBlueDarken20 = ColorAsset(name: "Colors/brand.blue.darken.20")
|
internal static let brandBlueDarken20 = ColorAsset(name: "Colors/brand.blue.darken.20")
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,20 +1,20 @@
|
||||||
{
|
{
|
||||||
"info" : {
|
|
||||||
"version" : 1,
|
|
||||||
"author" : "xcode"
|
|
||||||
},
|
|
||||||
"colors" : [
|
"colors" : [
|
||||||
{
|
{
|
||||||
"color" : {
|
"color" : {
|
||||||
"color-space" : "srgb",
|
"color-space" : "srgb",
|
||||||
"components" : {
|
"components" : {
|
||||||
"alpha" : "1.000",
|
"alpha" : "1.000",
|
||||||
"red" : "0.875",
|
"blue" : "90",
|
||||||
"blue" : "0.353",
|
"green" : "64",
|
||||||
"green" : "0.251"
|
"red" : "223"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"idiom" : "universal"
|
"idiom" : "universal"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -86,11 +86,10 @@ extension AccountListViewModel {
|
||||||
switch item {
|
switch item {
|
||||||
case .authentication(let objectID):
|
case .authentication(let objectID):
|
||||||
let authentication = managedObjectContext.object(with: objectID) as! MastodonAuthentication
|
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
|
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: AccountListTableViewCell.self), for: indexPath) as! AccountListTableViewCell
|
||||||
AccountListViewModel.configure(
|
AccountListViewModel.configure(
|
||||||
cell: cell,
|
cell: cell,
|
||||||
user: user,
|
authentication: authentication,
|
||||||
activeMastodonUserObjectID: self.activeMastodonUserObjectID.eraseToAnyPublisher()
|
activeMastodonUserObjectID: self.activeMastodonUserObjectID.eraseToAnyPublisher()
|
||||||
)
|
)
|
||||||
return cell
|
return cell
|
||||||
|
@ -107,9 +106,11 @@ extension AccountListViewModel {
|
||||||
|
|
||||||
static func configure(
|
static func configure(
|
||||||
cell: AccountListTableViewCell,
|
cell: AccountListTableViewCell,
|
||||||
user: MastodonUser,
|
authentication: MastodonAuthentication,
|
||||||
activeMastodonUserObjectID: AnyPublisher<NSManagedObjectID?, Never>
|
activeMastodonUserObjectID: AnyPublisher<NSManagedObjectID?, Never>
|
||||||
) {
|
) {
|
||||||
|
let user = authentication.user
|
||||||
|
|
||||||
// avatar
|
// avatar
|
||||||
cell.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: user.avatarImageURL()))
|
cell.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: user.avatarImageURL()))
|
||||||
|
|
||||||
|
@ -127,14 +128,32 @@ extension AccountListViewModel {
|
||||||
let usernameMetaContent = PlaintextMetaContent(string: "@" + user.acctWithDomain)
|
let usernameMetaContent = PlaintextMetaContent(string: "@" + user.acctWithDomain)
|
||||||
cell.usernameLabel.configure(content: usernameMetaContent)
|
cell.usernameLabel.configure(content: usernameMetaContent)
|
||||||
|
|
||||||
|
// badge
|
||||||
|
let accessToken = authentication.userAccessToken
|
||||||
|
let count = UserDefaults.shared.getNotificationCountWithAccessToken(accessToken: accessToken)
|
||||||
|
cell.badgeButton.setBadge(number: count)
|
||||||
|
|
||||||
// checkmark
|
// checkmark
|
||||||
activeMastodonUserObjectID
|
activeMastodonUserObjectID
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { objectID in
|
.sink { objectID in
|
||||||
let isCurrentUser = user.objectID == objectID
|
let isCurrentUser = user.objectID == objectID
|
||||||
cell.tintColor = .label
|
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)
|
.store(in: &cell.disposeBag)
|
||||||
|
|
||||||
|
cell.accessibilityLabel = [
|
||||||
|
cell.nameLabel.text,
|
||||||
|
cell.usernameLabel.text,
|
||||||
|
cell.badgeButton.accessibilityLabel
|
||||||
|
]
|
||||||
|
.compactMap { $0 }
|
||||||
|
.joined(separator: " ")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,6 +17,13 @@ final class AccountListTableViewCell: UITableViewCell {
|
||||||
let avatarButton = CircleAvatarButton(frame: .zero)
|
let avatarButton = CircleAvatarButton(frame: .zero)
|
||||||
let nameLabel = MetaLabel(style: .accountListName)
|
let nameLabel = MetaLabel(style: .accountListName)
|
||||||
let usernameLabel = MetaLabel(style: .accountListUsername)
|
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
|
let separatorLine = UIView.separatorLine
|
||||||
|
|
||||||
override func prepareForReuse() {
|
override func prepareForReuse() {
|
||||||
|
@ -63,15 +70,36 @@ extension AccountListTableViewCell {
|
||||||
labelContainerStackView.leadingAnchor.constraint(equalTo: avatarButton.trailingAnchor, constant: 10),
|
labelContainerStackView.leadingAnchor.constraint(equalTo: avatarButton.trailingAnchor, constant: 10),
|
||||||
contentView.bottomAnchor.constraint(equalTo: labelContainerStackView.bottomAnchor, constant: 10),
|
contentView.bottomAnchor.constraint(equalTo: labelContainerStackView.bottomAnchor, constant: 10),
|
||||||
avatarButton.heightAnchor.constraint(equalTo: labelContainerStackView.heightAnchor, multiplier: 0.8).priority(.required - 10),
|
avatarButton.heightAnchor.constraint(equalTo: labelContainerStackView.heightAnchor, multiplier: 0.8).priority(.required - 10),
|
||||||
labelContainerStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
|
||||||
])
|
])
|
||||||
|
|
||||||
labelContainerStackView.addArrangedSubview(nameLabel)
|
labelContainerStackView.addArrangedSubview(nameLabel)
|
||||||
labelContainerStackView.addArrangedSubview(usernameLabel)
|
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
|
avatarButton.isUserInteractionEnabled = false
|
||||||
nameLabel.isUserInteractionEnabled = false
|
nameLabel.isUserInteractionEnabled = false
|
||||||
usernameLabel.isUserInteractionEnabled = false
|
usernameLabel.isUserInteractionEnabled = false
|
||||||
|
badgeButton.isUserInteractionEnabled = false
|
||||||
|
|
||||||
separatorLine.translatesAutoresizingMaskIntoConstraints = false
|
separatorLine.translatesAutoresizingMaskIntoConstraints = false
|
||||||
contentView.addSubview(separatorLine)
|
contentView.addSubview(separatorLine)
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -26,14 +26,7 @@ extension HomeTimelineViewController {
|
||||||
showMenu,
|
showMenu,
|
||||||
moveMenu,
|
moveMenu,
|
||||||
dropMenu,
|
dropMenu,
|
||||||
UIAction(title: "Toggle EmptyView", image: UIImage(systemName: "clear"), attributes: []) { [weak self] action in
|
miscMenu,
|
||||||
guard let self = self else { return }
|
|
||||||
if self.emptyView.superview != nil {
|
|
||||||
self.emptyView.removeFromSuperview()
|
|
||||||
} else {
|
|
||||||
self.showEmptyView()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
UIAction(title: "Settings", image: UIImage(systemName: "gear"), attributes: []) { [weak self] action in
|
UIAction(title: "Settings", image: UIImage(systemName: "gear"), attributes: []) { [weak self] action in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.showSettings(action)
|
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 {
|
extension HomeTimelineViewController {
|
||||||
|
|
|
@ -194,12 +194,20 @@ extension MainTabBarController {
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
// handle push notification. toggle entry when finish fetch latest notification
|
// 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)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] hasUnreadPushNotification in
|
.sink { [weak self] authentication, _ in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
guard let notificationViewController = self.notificationViewController 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")!
|
let image = hasUnreadPushNotification ? UIImage(systemName: "bell.badge.fill")! : UIImage(systemName: "bell.fill")!
|
||||||
notificationViewController.tabBarItem.image = image
|
notificationViewController.tabBarItem.image = image
|
||||||
notificationViewController.navigationController?.tabBarItem.image = image
|
notificationViewController.navigationController?.tabBarItem.image = image
|
||||||
|
|
|
@ -166,6 +166,16 @@ extension NotificationViewController {
|
||||||
self.viewModel.loadLatestStateMachine.enter(NotificationViewModel.LoadLatestState.Loading.self)
|
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) {
|
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
|
||||||
|
|
|
@ -67,8 +67,6 @@ extension NotificationViewModel.LoadLatestState {
|
||||||
viewModel.isFetchingLatestNotification.value = false
|
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)
|
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:
|
case .finished:
|
||||||
// toggle unread state
|
|
||||||
viewModel.context.notificationService.hasUnreadPushNotification.value = false
|
|
||||||
// handle isFetchingLatestTimeline in fetch controller delegate
|
// handle isFetchingLatestTimeline in fetch controller delegate
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
|
@ -107,4 +107,28 @@ extension WizardCardView {
|
||||||
backgroundShapeLayer.fillColor = UIColor.white.cgColor
|
backgroundShapeLayer.fillColor = UIColor.white.cgColor
|
||||||
backgroundShapeLayer.path = path.cgPath
|
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 { }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,11 +24,12 @@ final class NotificationService {
|
||||||
weak var authenticationService: AuthenticationService?
|
weak var authenticationService: AuthenticationService?
|
||||||
let isNotificationPermissionGranted = CurrentValueSubject<Bool, Never>(false)
|
let isNotificationPermissionGranted = CurrentValueSubject<Bool, Never>(false)
|
||||||
let deviceToken = CurrentValueSubject<Data?, Never>(nil)
|
let deviceToken = CurrentValueSubject<Data?, Never>(nil)
|
||||||
|
let applicationIconBadgeNeedsUpdate = CurrentValueSubject<Void, Never>(Void())
|
||||||
|
|
||||||
// output
|
// output
|
||||||
/// [Token: UserID]
|
/// [Token: UserID]
|
||||||
let notificationSubscriptionDict: [String: NotificationViewModel] = [:]
|
let notificationSubscriptionDict: [String: NotificationViewModel] = [:]
|
||||||
let hasUnreadPushNotification = CurrentValueSubject<Bool, Never>(false)
|
let unreadNotificationCountDidUpdate = CurrentValueSubject<Void, Never>(Void())
|
||||||
let requestRevealNotificationPublisher = PassthroughSubject<Mastodon.Entity.Notification.ID, Never>()
|
let requestRevealNotificationPublisher = PassthroughSubject<Mastodon.Entity.Notification.ID, Never>()
|
||||||
|
|
||||||
init(
|
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)
|
os_log(.info, log: .api, "%{public}s[%{public}ld], %{public}s: deviceToken: %s", ((#file as NSString).lastPathComponent), #line, #function, token)
|
||||||
}
|
}
|
||||||
.store(in: &disposeBag)
|
.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) {
|
func handle(mastodonPushNotification: MastodonPushNotification) {
|
||||||
hasUnreadPushNotification.value = true
|
defer {
|
||||||
|
unreadNotificationCountDidUpdate.send()
|
||||||
|
}
|
||||||
|
|
||||||
// Subscription maybe failed to cancel when sign-out
|
// Subscription maybe failed to cancel when sign-out
|
||||||
// Try cancel again if receive that kind push notification
|
// 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
|
// MARK: - NotificationViewModel
|
||||||
|
|
||||||
extension NotificationService {
|
extension NotificationService {
|
||||||
|
|
|
@ -100,6 +100,11 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
|
||||||
|
|
||||||
let notificationID = String(mastodonPushNotification.notificationID)
|
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)
|
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)
|
appContext.notificationService.handle(mastodonPushNotification: mastodonPushNotification)
|
||||||
completionHandler([.sound])
|
completionHandler([.sound])
|
||||||
}
|
}
|
||||||
|
|
|
@ -87,9 +87,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
// Called when the scene has moved from an inactive state to an active state.
|
// 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.
|
// Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
|
||||||
|
|
||||||
// reset notification badge
|
// update application badge
|
||||||
UserDefaults.shared.notificationBadgeCount = 0
|
AppContext.shared.notificationService.applicationIconBadgeNeedsUpdate.send()
|
||||||
UIApplication.shared.applicationIconBadgeNumber = 0
|
|
||||||
|
|
||||||
// trigger status filter update
|
// trigger status filter update
|
||||||
AppContext.shared.statusFilterService.filterUpdatePublisher.send()
|
AppContext.shared.statusFilterService.filterUpdatePublisher.send()
|
||||||
|
|
|
@ -60,6 +60,9 @@ class NotificationService: UNNotificationServiceExtension {
|
||||||
bestAttemptContent.sound = UNNotificationSound.init(named: UNNotificationSoundName(rawValue: "BoopSound.caf"))
|
bestAttemptContent.sound = UNNotificationSound.init(named: UNNotificationSoundName(rawValue: "BoopSound.caf"))
|
||||||
bestAttemptContent.userInfo["plaintext"] = plaintextData
|
bestAttemptContent.userInfo["plaintext"] = plaintextData
|
||||||
|
|
||||||
|
let accessToken = notification.accessToken
|
||||||
|
UserDefaults.shared.increaseNotificationCount(accessToken: accessToken)
|
||||||
|
|
||||||
UserDefaults.shared.notificationBadgeCount += 1
|
UserDefaults.shared.notificationBadgeCount += 1
|
||||||
bestAttemptContent.badge = NSNumber(integerLiteral: UserDefaults.shared.notificationBadgeCount)
|
bestAttemptContent.badge = NSNumber(integerLiteral: UserDefaults.shared.notificationBadgeCount)
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue