Merge pull request #161 from tootsuite/fix/compose
Fix thread and compose scene UI/UX issues
This commit is contained in:
commit
3060a579c5
|
@ -187,7 +187,7 @@
|
|||
"blocking_warning": "You can’t view Artbot’s profile\n until you unblock them.\nYour account looks like this to them.",
|
||||
"blocked_warning": "You can’t view Artbot’s profile\n until they unblock you.",
|
||||
"suspended_warning": "This account has been suspended.",
|
||||
"user_suspended_warning": "%s's account has been suspended."
|
||||
"user_suspended_warning": "%s’s account has been suspended."
|
||||
},
|
||||
"accessibility": {
|
||||
"count_replies": "%s replies",
|
||||
|
@ -290,7 +290,7 @@
|
|||
},
|
||||
"special": {
|
||||
"username_invalid": "Username must only contain alphanumeric characters and underscores",
|
||||
"username_too_long": "Username is too long (can't be longer than 30 characters)",
|
||||
"username_too_long": "Username is too long (can’t be longer than 30 characters)",
|
||||
"email_invalid": "This is not a valid e-mail address",
|
||||
"password_too_short": "Password is too short (must be at least 8 characters)"
|
||||
}
|
||||
|
@ -299,7 +299,7 @@
|
|||
"server_rules": {
|
||||
"title": "Some ground rules.",
|
||||
"subtitle": "These rules are set by the admins of %s.",
|
||||
"prompt": "By continuing, you're subject to the terms of service and privacy policy for %s.",
|
||||
"prompt": "By continuing, you’re subject to the terms of service and privacy policy for %s.",
|
||||
"terms_of_service": "terms of service",
|
||||
"privacy_policy": "privacy policy",
|
||||
"button": {
|
||||
|
@ -351,13 +351,13 @@
|
|||
"photo_library": "Photo Library",
|
||||
"browse": "Browse"
|
||||
},
|
||||
"content_input_placeholder": "Type or paste what's on your mind",
|
||||
"content_input_placeholder": "Type or paste what’s on your mind",
|
||||
"compose_action": "Publish",
|
||||
"replying_to_user": "replying to %s",
|
||||
"attachment": {
|
||||
"photo": "photo",
|
||||
"video": "video",
|
||||
"attachment_broken": "This %s is broken and can't be\nuploaded to Mastodon.",
|
||||
"attachment_broken": "This %s is broken and can’t be\nuploaded to Mastodon.",
|
||||
"description_photo": "Describe photo for low vision people...",
|
||||
"description_video": "Describe what’s happening for low vision people..."
|
||||
},
|
||||
|
@ -382,7 +382,8 @@
|
|||
},
|
||||
"auto_complete": {
|
||||
"single_people_talking": "%ld people talking",
|
||||
"multiple_people_talking": "%ld people talking"
|
||||
"multiple_people_talking": "%ld people talking",
|
||||
"space_to_add": "Space to add"
|
||||
},
|
||||
"accessibility": {
|
||||
"append_attachment": "Append attachment",
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
<key>CoreDataStack.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>17</integer>
|
||||
<integer>16</integer>
|
||||
</dict>
|
||||
<key>Mastodon - RTL.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
|
@ -32,7 +32,7 @@
|
|||
<key>NotificationService.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>16</integer>
|
||||
<integer>17</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
|
|
|
@ -33,7 +33,7 @@ extension AutoCompleteSection {
|
|||
return cell
|
||||
case .emoji(let emoji):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: AutoCompleteTableViewCell.self), for: indexPath) as! AutoCompleteTableViewCell
|
||||
configureEmoji(cell: cell, emoji: emoji)
|
||||
configureEmoji(cell: cell, emoji: emoji, isFirst: indexPath.row == 0)
|
||||
return cell
|
||||
case .bottomLoader:
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell
|
||||
|
@ -80,8 +80,10 @@ extension AutoCompleteSection {
|
|||
cell.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: URL(string: account.avatar)))
|
||||
}
|
||||
|
||||
private static func configureEmoji(cell: AutoCompleteTableViewCell, emoji: Mastodon.Entity.Emoji) {
|
||||
private static func configureEmoji(cell: AutoCompleteTableViewCell, emoji: Mastodon.Entity.Emoji, isFirst: Bool) {
|
||||
cell.titleLabel.text = ":" + emoji.shortcode + ":"
|
||||
// FIXME: handle spacer enter to complete emoji
|
||||
// cell.subtitleLabel.text = isFirst ? L10n.Scene.Compose.AutoComplete.spaceToAdd : " "
|
||||
cell.subtitleLabel.text = " "
|
||||
cell.avatarImageView.isHidden = false
|
||||
cell.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: URL(string: emoji.url)))
|
||||
|
|
|
@ -47,14 +47,18 @@ extension StatusSection {
|
|||
|
||||
// configure cell
|
||||
managedObjectContext.performAndWait {
|
||||
let timelineIndex = managedObjectContext.object(with: objectID) as! HomeTimelineIndex
|
||||
let timelineIndex = managedObjectContext.object(with: objectID) as? HomeTimelineIndex
|
||||
// note: force check optional for status
|
||||
// status maybe <uninitialized> here when delete in thread scene
|
||||
guard let status = timelineIndex?.status,
|
||||
let userID = timelineIndex?.userID else { return }
|
||||
StatusSection.configure(
|
||||
cell: cell,
|
||||
dependency: dependency,
|
||||
readableLayoutFrame: tableView.readableContentGuide.layoutFrame,
|
||||
timestampUpdatePublisher: timestampUpdatePublisher,
|
||||
status: timelineIndex.status,
|
||||
requestUserID: timelineIndex.userID,
|
||||
status: status,
|
||||
requestUserID: userID,
|
||||
statusItemAttribute: attribute
|
||||
)
|
||||
}
|
||||
|
@ -752,12 +756,13 @@ extension StatusSection {
|
|||
return L10n.Common.Controls.Timeline.Accessibility.countReblogs(status.favouritesCount.intValue)
|
||||
}()
|
||||
Publishers.CombineLatest(
|
||||
dependency.context.blockDomainService.blockedDomains,
|
||||
dependency.context.blockDomainService.blockedDomains.setFailureType(to: ManagedObjectObserver.Error.self),
|
||||
ManagedObjectObserver.observe(object: status.authorForUserProvider)
|
||||
.assertNoFailure()
|
||||
)
|
||||
)
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak dependency, weak cell] _, change in
|
||||
.sink(receiveCompletion: { _ in
|
||||
// do nothing
|
||||
}, receiveValue: { [weak dependency, weak cell] _, change in
|
||||
guard let cell = cell else { return }
|
||||
guard let dependency = dependency else { return }
|
||||
switch change.changeType {
|
||||
|
@ -769,7 +774,7 @@ extension StatusSection {
|
|||
break
|
||||
}
|
||||
StatusSection.setupStatusMoreButtonMenu(cell: cell, dependency: dependency, status: status)
|
||||
}
|
||||
})
|
||||
.store(in: &cell.disposeBag)
|
||||
self.setupStatusMoreButtonMenu(cell: cell, dependency: dependency, status: status)
|
||||
}
|
||||
|
|
|
@ -367,7 +367,7 @@ internal enum L10n {
|
|||
internal static let noStatusFound = L10n.tr("Localizable", "Common.Controls.Timeline.Header.NoStatusFound")
|
||||
/// This account has been suspended.
|
||||
internal static let suspendedWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.SuspendedWarning")
|
||||
/// %@'s account has been suspended.
|
||||
/// %@’s account has been suspended.
|
||||
internal static func userSuspendedWarning(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "Common.Controls.Timeline.Header.UserSuspendedWarning", String(describing: p1))
|
||||
}
|
||||
|
@ -404,7 +404,7 @@ internal enum L10n {
|
|||
internal enum Compose {
|
||||
/// Publish
|
||||
internal static let composeAction = L10n.tr("Localizable", "Scene.Compose.ComposeAction")
|
||||
/// Type or paste what's on your mind
|
||||
/// Type or paste what’s on your mind
|
||||
internal static let contentInputPlaceholder = L10n.tr("Localizable", "Scene.Compose.ContentInputPlaceholder")
|
||||
/// replying to %@
|
||||
internal static func replyingToUser(_ p1: Any) -> String {
|
||||
|
@ -435,7 +435,7 @@ internal enum L10n {
|
|||
internal static let removePoll = L10n.tr("Localizable", "Scene.Compose.Accessibility.RemovePoll")
|
||||
}
|
||||
internal enum Attachment {
|
||||
/// This %@ is broken and can't be\nuploaded to Mastodon.
|
||||
/// This %@ is broken and can’t be\nuploaded to Mastodon.
|
||||
internal static func attachmentBroken(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "Scene.Compose.Attachment.AttachmentBroken", String(describing: p1))
|
||||
}
|
||||
|
@ -457,6 +457,8 @@ internal enum L10n {
|
|||
internal static func singlePeopleTalking(_ p1: Int) -> String {
|
||||
return L10n.tr("Localizable", "Scene.Compose.AutoComplete.SinglePeopleTalking", p1)
|
||||
}
|
||||
/// Space to add
|
||||
internal static let spaceToAdd = L10n.tr("Localizable", "Scene.Compose.AutoComplete.SpaceToAdd")
|
||||
}
|
||||
internal enum ContentWarning {
|
||||
/// Write an accurate warning here...
|
||||
|
@ -756,7 +758,7 @@ internal enum L10n {
|
|||
internal static let passwordTooShort = L10n.tr("Localizable", "Scene.Register.Error.Special.PasswordTooShort")
|
||||
/// Username must only contain alphanumeric characters and underscores
|
||||
internal static let usernameInvalid = L10n.tr("Localizable", "Scene.Register.Error.Special.UsernameInvalid")
|
||||
/// Username is too long (can't be longer than 30 characters)
|
||||
/// Username is too long (can’t be longer than 30 characters)
|
||||
internal static let usernameTooLong = L10n.tr("Localizable", "Scene.Register.Error.Special.UsernameTooLong")
|
||||
}
|
||||
}
|
||||
|
@ -918,7 +920,7 @@ internal enum L10n {
|
|||
internal enum ServerRules {
|
||||
/// privacy policy
|
||||
internal static let privacyPolicy = L10n.tr("Localizable", "Scene.ServerRules.PrivacyPolicy")
|
||||
/// By continuing, you're subject to the terms of service and privacy policy for %@.
|
||||
/// By continuing, you’re subject to the terms of service and privacy policy for %@.
|
||||
internal static func prompt(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "Scene.ServerRules.Prompt", String(describing: p1))
|
||||
}
|
||||
|
|
|
@ -128,7 +128,7 @@ Please check your internet connection.";
|
|||
Your account looks like this to them.";
|
||||
"Common.Controls.Timeline.Header.NoStatusFound" = "No Status Found";
|
||||
"Common.Controls.Timeline.Header.SuspendedWarning" = "This account has been suspended.";
|
||||
"Common.Controls.Timeline.Header.UserSuspendedWarning" = "%@'s account has been suspended.";
|
||||
"Common.Controls.Timeline.Header.UserSuspendedWarning" = "%@’s account has been suspended.";
|
||||
"Common.Controls.Timeline.Loader.LoadMissingPosts" = "Load missing posts";
|
||||
"Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts...";
|
||||
"Common.Controls.Timeline.Loader.ShowMoreReplies" = "Show more replies";
|
||||
|
@ -145,7 +145,7 @@ Your account looks like this to them.";
|
|||
"Scene.Compose.Accessibility.InputLimitRemainsCount" = "Input limit remains %ld";
|
||||
"Scene.Compose.Accessibility.PostVisibilityMenu" = "Post visibility menu";
|
||||
"Scene.Compose.Accessibility.RemovePoll" = "Remove poll";
|
||||
"Scene.Compose.Attachment.AttachmentBroken" = "This %@ is broken and can't be
|
||||
"Scene.Compose.Attachment.AttachmentBroken" = "This %@ is broken and can’t be
|
||||
uploaded to Mastodon.";
|
||||
"Scene.Compose.Attachment.DescriptionPhoto" = "Describe photo for low vision people...";
|
||||
"Scene.Compose.Attachment.DescriptionVideo" = "Describe what’s happening for low vision people...";
|
||||
|
@ -153,8 +153,9 @@ uploaded to Mastodon.";
|
|||
"Scene.Compose.Attachment.Video" = "video";
|
||||
"Scene.Compose.AutoComplete.MultiplePeopleTalking" = "%ld people talking";
|
||||
"Scene.Compose.AutoComplete.SinglePeopleTalking" = "%ld people talking";
|
||||
"Scene.Compose.AutoComplete.SpaceToAdd" = "Space to add";
|
||||
"Scene.Compose.ComposeAction" = "Publish";
|
||||
"Scene.Compose.ContentInputPlaceholder" = "Type or paste what's on your mind";
|
||||
"Scene.Compose.ContentInputPlaceholder" = "Type or paste what’s on your mind";
|
||||
"Scene.Compose.ContentWarning.Placeholder" = "Write an accurate warning here...";
|
||||
"Scene.Compose.Keyboard.AppendAttachmentEntry" = "Append Attachment - %@";
|
||||
"Scene.Compose.Keyboard.DiscardPost" = "Discard Post";
|
||||
|
@ -249,7 +250,7 @@ tap the link to confirm your account.";
|
|||
"Scene.Register.Error.Special.EmailInvalid" = "This is not a valid e-mail address";
|
||||
"Scene.Register.Error.Special.PasswordTooShort" = "Password is too short (must be at least 8 characters)";
|
||||
"Scene.Register.Error.Special.UsernameInvalid" = "Username must only contain alphanumeric characters and underscores";
|
||||
"Scene.Register.Error.Special.UsernameTooLong" = "Username is too long (can't be longer than 30 characters)";
|
||||
"Scene.Register.Error.Special.UsernameTooLong" = "Username is too long (can’t be longer than 30 characters)";
|
||||
"Scene.Register.Input.Avatar.Delete" = "Delete";
|
||||
"Scene.Register.Input.DisplayName.Placeholder" = "display name";
|
||||
"Scene.Register.Input.Email.Placeholder" = "email";
|
||||
|
@ -308,7 +309,7 @@ tap the link to confirm your account.";
|
|||
any server.";
|
||||
"Scene.ServerRules.Button.Confirm" = "I Agree";
|
||||
"Scene.ServerRules.PrivacyPolicy" = "privacy policy";
|
||||
"Scene.ServerRules.Prompt" = "By continuing, you're subject to the terms of service and privacy policy for %@.";
|
||||
"Scene.ServerRules.Prompt" = "By continuing, you’re subject to the terms of service and privacy policy for %@.";
|
||||
"Scene.ServerRules.Subtitle" = "These rules are set by the admins of %@.";
|
||||
"Scene.ServerRules.TermsOfService" = "terms of service";
|
||||
"Scene.ServerRules.Title" = "Some ground rules.";
|
||||
|
|
|
@ -128,7 +128,7 @@ Please check your internet connection.";
|
|||
Your account looks like this to them.";
|
||||
"Common.Controls.Timeline.Header.NoStatusFound" = "No Status Found";
|
||||
"Common.Controls.Timeline.Header.SuspendedWarning" = "This account has been suspended.";
|
||||
"Common.Controls.Timeline.Header.UserSuspendedWarning" = "%@'s account has been suspended.";
|
||||
"Common.Controls.Timeline.Header.UserSuspendedWarning" = "%@’s account has been suspended.";
|
||||
"Common.Controls.Timeline.Loader.LoadMissingPosts" = "Load missing posts";
|
||||
"Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts...";
|
||||
"Common.Controls.Timeline.Loader.ShowMoreReplies" = "Show more replies";
|
||||
|
@ -145,7 +145,7 @@ Your account looks like this to them.";
|
|||
"Scene.Compose.Accessibility.InputLimitRemainsCount" = "Input limit remains %ld";
|
||||
"Scene.Compose.Accessibility.PostVisibilityMenu" = "Post visibility menu";
|
||||
"Scene.Compose.Accessibility.RemovePoll" = "Remove poll";
|
||||
"Scene.Compose.Attachment.AttachmentBroken" = "This %@ is broken and can't be
|
||||
"Scene.Compose.Attachment.AttachmentBroken" = "This %@ is broken and can’t be
|
||||
uploaded to Mastodon.";
|
||||
"Scene.Compose.Attachment.DescriptionPhoto" = "Describe photo for low vision people...";
|
||||
"Scene.Compose.Attachment.DescriptionVideo" = "Describe what’s happening for low vision people...";
|
||||
|
@ -153,8 +153,9 @@ uploaded to Mastodon.";
|
|||
"Scene.Compose.Attachment.Video" = "video";
|
||||
"Scene.Compose.AutoComplete.MultiplePeopleTalking" = "%ld people talking";
|
||||
"Scene.Compose.AutoComplete.SinglePeopleTalking" = "%ld people talking";
|
||||
"Scene.Compose.AutoComplete.SpaceToAdd" = "Space to add";
|
||||
"Scene.Compose.ComposeAction" = "Publish";
|
||||
"Scene.Compose.ContentInputPlaceholder" = "Type or paste what's on your mind";
|
||||
"Scene.Compose.ContentInputPlaceholder" = "Type or paste what’s on your mind";
|
||||
"Scene.Compose.ContentWarning.Placeholder" = "Write an accurate warning here...";
|
||||
"Scene.Compose.Keyboard.AppendAttachmentEntry" = "Append Attachment - %@";
|
||||
"Scene.Compose.Keyboard.DiscardPost" = "Discard Post";
|
||||
|
@ -249,7 +250,7 @@ tap the link to confirm your account.";
|
|||
"Scene.Register.Error.Special.EmailInvalid" = "This is not a valid e-mail address";
|
||||
"Scene.Register.Error.Special.PasswordTooShort" = "Password is too short (must be at least 8 characters)";
|
||||
"Scene.Register.Error.Special.UsernameInvalid" = "Username must only contain alphanumeric characters and underscores";
|
||||
"Scene.Register.Error.Special.UsernameTooLong" = "Username is too long (can't be longer than 30 characters)";
|
||||
"Scene.Register.Error.Special.UsernameTooLong" = "Username is too long (can’t be longer than 30 characters)";
|
||||
"Scene.Register.Input.Avatar.Delete" = "Delete";
|
||||
"Scene.Register.Input.DisplayName.Placeholder" = "display name";
|
||||
"Scene.Register.Input.Email.Placeholder" = "email";
|
||||
|
@ -308,7 +309,7 @@ tap the link to confirm your account.";
|
|||
any server.";
|
||||
"Scene.ServerRules.Button.Confirm" = "I Agree";
|
||||
"Scene.ServerRules.PrivacyPolicy" = "privacy policy";
|
||||
"Scene.ServerRules.Prompt" = "By continuing, you're subject to the terms of service and privacy policy for %@.";
|
||||
"Scene.ServerRules.Prompt" = "By continuing, you’re subject to the terms of service and privacy policy for %@.";
|
||||
"Scene.ServerRules.Subtitle" = "These rules are set by the admins of %@.";
|
||||
"Scene.ServerRules.TermsOfService" = "terms of service";
|
||||
"Scene.ServerRules.Title" = "Some ground rules.";
|
||||
|
|
|
@ -45,6 +45,8 @@ final class AutoCompleteTableViewCell: UITableViewCell {
|
|||
return label
|
||||
}()
|
||||
|
||||
let separatorLine = UIView.separatorLine
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
avatarImageView.af.cancelImageRequest()
|
||||
|
@ -118,6 +120,15 @@ extension AutoCompleteTableViewCell {
|
|||
bottomPaddingView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
||||
bottomPaddingView.heightAnchor.constraint(equalTo: topPaddingView.heightAnchor, multiplier: 1.0),
|
||||
])
|
||||
|
||||
separatorLine.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(separatorLine)
|
||||
NSLayoutConstraint.activate([
|
||||
separatorLine.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
||||
separatorLine.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
||||
separatorLine.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
||||
separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)).priority(.defaultHigh),
|
||||
])
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -55,6 +55,7 @@ final class ComposeViewController: UIViewController, NeedsDependency {
|
|||
collectionView.register(ComposeStatusPollExpiresOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollExpiresOptionCollectionViewCell.self))
|
||||
collectionView.backgroundColor = Asset.Scene.Compose.background.color
|
||||
collectionView.alwaysBounceVertical = true
|
||||
collectionView.keyboardDismissMode = .onDrag
|
||||
return collectionView
|
||||
}()
|
||||
|
||||
|
@ -380,11 +381,11 @@ extension ComposeViewController {
|
|||
self.composeToolbarView.characterCountLabel.text = "\(count)"
|
||||
switch count {
|
||||
case _ where count < 0:
|
||||
self.composeToolbarView.characterCountLabel.font = .systemFont(ofSize: 24, weight: .bold)
|
||||
self.composeToolbarView.characterCountLabel.font = .monospacedDigitSystemFont(ofSize: 24, weight: .bold)
|
||||
self.composeToolbarView.characterCountLabel.textColor = Asset.Colors.danger.color
|
||||
self.composeToolbarView.characterCountLabel.accessibilityLabel = L10n.Scene.Compose.Accessibility.inputLimitExceedsCount(abs(count))
|
||||
default:
|
||||
self.composeToolbarView.characterCountLabel.font = .systemFont(ofSize: 15, weight: .regular)
|
||||
self.composeToolbarView.characterCountLabel.font = .monospacedDigitSystemFont(ofSize: 15, weight: .regular)
|
||||
self.composeToolbarView.characterCountLabel.textColor = Asset.Colors.Label.secondary.color
|
||||
self.composeToolbarView.characterCountLabel.accessibilityLabel = L10n.Scene.Compose.Accessibility.inputLimitRemainsCount(count)
|
||||
}
|
||||
|
|
|
@ -69,6 +69,9 @@ extension ComposeViewModel {
|
|||
snapshot.appendItems([.input(replyToStatusObjectID: nil, attribute: composeStatusAttribute)], toSection: .status)
|
||||
}
|
||||
diffableDataSource.apply(snapshot, animatingDifferences: false)
|
||||
|
||||
// some magic fix modal presentation animation issue
|
||||
collectionView.dataSource = diffableDataSource
|
||||
}
|
||||
|
||||
func setupCustomEmojiPickerDiffableDataSource(
|
||||
|
|
|
@ -526,6 +526,10 @@ extension HomeTimelineViewController: StatusTableViewCellDelegate {
|
|||
|
||||
// MARK: - HomeTimelineNavigationBarTitleViewDelegate
|
||||
extension HomeTimelineViewController: HomeTimelineNavigationBarTitleViewDelegate {
|
||||
func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, logoButtonDidPressed sender: UIButton) {
|
||||
scrollToTop(animated: true)
|
||||
}
|
||||
|
||||
func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, buttonDidPressed sender: UIButton) {
|
||||
switch titleView.state {
|
||||
case .newPostButton:
|
||||
|
|
|
@ -9,6 +9,7 @@ import os.log
|
|||
import UIKit
|
||||
|
||||
protocol HomeTimelineNavigationBarTitleViewDelegate: AnyObject {
|
||||
func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, logoButtonDidPressed sender: UIButton)
|
||||
func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, buttonDidPressed sender: UIButton)
|
||||
}
|
||||
|
||||
|
@ -16,7 +17,7 @@ final class HomeTimelineNavigationBarTitleView: UIView {
|
|||
|
||||
let containerView = UIStackView()
|
||||
|
||||
let imageView = UIImageView()
|
||||
let logoButton = HighlightDimmableButton()
|
||||
let button = RoundedEdgesButton()
|
||||
let label = UILabel()
|
||||
|
||||
|
@ -25,7 +26,7 @@ final class HomeTimelineNavigationBarTitleView: UIView {
|
|||
weak var delegate: HomeTimelineNavigationBarTitleViewDelegate?
|
||||
|
||||
// output
|
||||
private(set) var state: HomeTimelineNavigationBarTitleViewModel.State = .logoImage
|
||||
private(set) var state: HomeTimelineNavigationBarTitleViewModel.State = .logo
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
@ -50,7 +51,7 @@ extension HomeTimelineNavigationBarTitleView {
|
|||
containerView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
])
|
||||
|
||||
containerView.addArrangedSubview(imageView)
|
||||
containerView.addArrangedSubview(logoButton)
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
containerView.addArrangedSubview(button)
|
||||
NSLayoutConstraint.activate([
|
||||
|
@ -58,12 +59,18 @@ extension HomeTimelineNavigationBarTitleView {
|
|||
])
|
||||
containerView.addArrangedSubview(label)
|
||||
|
||||
configure(state: .logoImage)
|
||||
configure(state: .logo)
|
||||
logoButton.addTarget(self, action: #selector(HomeTimelineNavigationBarTitleView.logoButtonDidPressed(_:)), for: .touchUpInside)
|
||||
button.addTarget(self, action: #selector(HomeTimelineNavigationBarTitleView.buttonDidPressed(_:)), for: .touchUpInside)
|
||||
}
|
||||
}
|
||||
|
||||
extension HomeTimelineNavigationBarTitleView {
|
||||
@objc private func logoButtonDidPressed(_ sender: UIButton) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
delegate?.homeTimelineNavigationBarTitleView(self, logoButtonDidPressed: sender)
|
||||
}
|
||||
|
||||
@objc private func buttonDidPressed(_ sender: UIButton) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
delegate?.homeTimelineNavigationBarTitleView(self, buttonDidPressed: sender)
|
||||
|
@ -73,7 +80,7 @@ extension HomeTimelineNavigationBarTitleView {
|
|||
extension HomeTimelineNavigationBarTitleView {
|
||||
|
||||
func resetContainer() {
|
||||
imageView.isHidden = true
|
||||
logoButton.isHidden = true
|
||||
button.isHidden = true
|
||||
label.isHidden = true
|
||||
}
|
||||
|
@ -90,11 +97,11 @@ extension HomeTimelineNavigationBarTitleView {
|
|||
resetContainer()
|
||||
|
||||
switch state {
|
||||
case .logoImage:
|
||||
imageView.tintColor = Asset.Colors.Label.primary.color
|
||||
imageView.image = Asset.Asset.mastodonTextLogo.image.withRenderingMode(.alwaysTemplate)
|
||||
imageView.contentMode = .center
|
||||
imageView.isHidden = false
|
||||
case .logo:
|
||||
logoButton.tintColor = Asset.Colors.Label.primary.color
|
||||
logoButton.setImage(Asset.Asset.mastodonTextLogo.image.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||
logoButton.contentMode = .center
|
||||
logoButton.isHidden = false
|
||||
case .newPostButton:
|
||||
configureButton(
|
||||
title: L10n.Scene.HomeTimeline.NavigationBarState.newPosts,
|
||||
|
@ -173,7 +180,7 @@ struct HomeTimelineNavigationBarTitleView_Previews: PreviewProvider {
|
|||
Group {
|
||||
UIViewPreview(width: 375) {
|
||||
let titleView = HomeTimelineNavigationBarTitleView()
|
||||
titleView.configure(state: .logoImage)
|
||||
titleView.configure(state: .logo)
|
||||
return titleView
|
||||
}
|
||||
.previewLayout(.fixed(width: 375, height: 44))
|
||||
|
|
|
@ -22,7 +22,7 @@ final class HomeTimelineNavigationBarTitleViewModel {
|
|||
var networkErrorPublisher = PassthroughSubject<Void, Never>()
|
||||
|
||||
// output
|
||||
let state = CurrentValueSubject<State, Never>(.logoImage)
|
||||
let state = CurrentValueSubject<State, Never>(.logo)
|
||||
let hasNewPosts = CurrentValueSubject<Bool, Never>(false)
|
||||
let isOffline = CurrentValueSubject<Bool, Never>(false)
|
||||
let isPublishingPost = CurrentValueSubject<Bool, Never>(false)
|
||||
|
@ -75,7 +75,7 @@ final class HomeTimelineNavigationBarTitleViewModel {
|
|||
guard !isPublishingPost else { return .publishingPostLabel }
|
||||
guard !isOffline else { return .offlineButton }
|
||||
guard !hasNewPosts else { return .newPostButton }
|
||||
return .logoImage
|
||||
return .logo
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: \.value, on: state)
|
||||
|
@ -100,7 +100,7 @@ final class HomeTimelineNavigationBarTitleViewModel {
|
|||
extension HomeTimelineNavigationBarTitleViewModel {
|
||||
// state order by priority from low to high
|
||||
enum State: String {
|
||||
case logoImage
|
||||
case logo
|
||||
case newPostButton
|
||||
case offlineButton
|
||||
case publishingPostLabel
|
||||
|
|
|
@ -8,6 +8,8 @@
|
|||
import UIKit
|
||||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
|
||||
extension ThreadViewModel {
|
||||
|
||||
|
@ -41,13 +43,29 @@ extension ThreadViewModel {
|
|||
diffableDataSource?.apply(snapshot, animatingDifferences: false, completion: nil)
|
||||
|
||||
Publishers.CombineLatest3(
|
||||
rootItem.removeDuplicates(),
|
||||
ancestorItems.removeDuplicates(),
|
||||
descendantItems.removeDuplicates()
|
||||
)
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] rootItem, ancestorItems, descendantItems in
|
||||
guard let self = self else { return }
|
||||
var items: [Item] = []
|
||||
rootItem.flatMap { items.append($0) }
|
||||
items.append(contentsOf: ancestorItems)
|
||||
items.append(contentsOf: descendantItems)
|
||||
self.updateDeletedStatus(for: items)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
Publishers.CombineLatest4(
|
||||
rootItem,
|
||||
ancestorItems,
|
||||
descendantItems
|
||||
descendantItems,
|
||||
existStatusFetchedResultsController.objectIDs
|
||||
)
|
||||
.debounce(for: .milliseconds(100), scheduler: DispatchQueue.main) // some magic to avoid jitter
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] rootItem, ancestorItems, descendantItems in
|
||||
.debounce(for: .milliseconds(100), scheduler: RunLoop.main) // some magic to avoid jitter
|
||||
.sink { [weak self] rootItem, ancestorItems, descendantItems, existObjectIDs in
|
||||
guard let self = self else { return }
|
||||
guard let tableView = self.tableView,
|
||||
let navigationBar = self.contentOffsetAdjustableTimelineViewControllerDelegate?.navigationBar()
|
||||
|
@ -65,31 +83,42 @@ extension ThreadViewModel {
|
|||
if self.rootNode.value?.replyToID != nil, !(currentState is LoadThreadState.NoMore) {
|
||||
newSnapshot.appendItems([.topLoader], toSection: .main)
|
||||
}
|
||||
|
||||
let ancestorItems = ancestorItems.filter { item in
|
||||
guard case let .reply(statusObjectID, _) = item else { return false }
|
||||
return existObjectIDs.contains(statusObjectID)
|
||||
}
|
||||
newSnapshot.appendItems(ancestorItems, toSection: .main)
|
||||
|
||||
// root
|
||||
if let rootItem = rootItem {
|
||||
switch rootItem {
|
||||
case .root:
|
||||
newSnapshot.appendItems([rootItem], toSection: .main)
|
||||
default:
|
||||
break
|
||||
}
|
||||
if let rootItem = rootItem,
|
||||
case let .root(objectID, _) = rootItem,
|
||||
existObjectIDs.contains(objectID) {
|
||||
newSnapshot.appendItems([rootItem], toSection: .main)
|
||||
}
|
||||
|
||||
// leaf
|
||||
if !(currentState is LoadThreadState.NoMore) {
|
||||
newSnapshot.appendItems([.bottomLoader], toSection: .main)
|
||||
}
|
||||
|
||||
let descendantItems = descendantItems.filter { item in
|
||||
switch item {
|
||||
case .leaf(let statusObjectID, _):
|
||||
return existObjectIDs.contains(statusObjectID)
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
newSnapshot.appendItems(descendantItems, toSection: .main)
|
||||
|
||||
// difference for first visiable item exclude .topLoader
|
||||
// difference for first visible item exclude .topLoader
|
||||
guard let difference = self.calculateReloadSnapshotDifference(navigationBar: navigationBar, tableView: tableView, oldSnapshot: oldSnapshot, newSnapshot: newSnapshot) else {
|
||||
diffableDataSource.apply(newSnapshot)
|
||||
return
|
||||
}
|
||||
|
||||
// addtional margin for .topLoader
|
||||
// additional margin for .topLoader
|
||||
let oldTopMargin: CGFloat = {
|
||||
let marginHeight = TimelineTopLoaderTableViewCell.cellHeight
|
||||
if oldSnapshot.itemIdentifiers.contains(.topLoader) {
|
||||
|
@ -184,3 +213,33 @@ extension ThreadViewModel {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension ThreadViewModel {
|
||||
private func updateDeletedStatus(for items: [Item]) {
|
||||
let parentManagedObjectContext = context.managedObjectContext
|
||||
let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
|
||||
managedObjectContext.parent = parentManagedObjectContext
|
||||
managedObjectContext.perform {
|
||||
var statusIDs: [Status.ID] = []
|
||||
for item in items {
|
||||
switch item {
|
||||
case .root(let objectID, _):
|
||||
guard let status = managedObjectContext.object(with: objectID) as? Status else { continue }
|
||||
statusIDs.append(status.id)
|
||||
case .reply(let objectID, _):
|
||||
guard let status = managedObjectContext.object(with: objectID) as? Status else { continue }
|
||||
statusIDs.append(status.id)
|
||||
case .leaf(let objectID, _):
|
||||
guard let status = managedObjectContext.object(with: objectID) as? Status else { continue }
|
||||
statusIDs.append(status.id)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.existStatusFetchedResultsController.statusIDs.value = statusIDs
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,12 +16,14 @@ import MastodonSDK
|
|||
class ThreadViewModel {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
var rootItemObserver: AnyCancellable?
|
||||
|
||||
// input
|
||||
let context: AppContext
|
||||
let rootNode: CurrentValueSubject<RootNode?, Never>
|
||||
let rootItem: CurrentValueSubject<Item?, Never>
|
||||
let cellFrameCache = NSCache<NSNumber, NSValue>()
|
||||
let existStatusFetchedResultsController: StatusFetchedResultsController
|
||||
|
||||
weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate?
|
||||
weak var tableView: UITableView?
|
||||
|
@ -49,10 +51,20 @@ class ThreadViewModel {
|
|||
self.context = context
|
||||
self.rootNode = CurrentValueSubject(optionalStatus.flatMap { RootNode(domain: $0.domain, statusID: $0.id, replyToID: $0.inReplyToID) })
|
||||
self.rootItem = CurrentValueSubject(optionalStatus.flatMap { Item.root(statusObjectID: $0.objectID, attribute: Item.StatusAttribute()) })
|
||||
self.existStatusFetchedResultsController = StatusFetchedResultsController(managedObjectContext: context.managedObjectContext, domain: nil, additionalTweetPredicate: nil)
|
||||
self.navigationBarTitle = CurrentValueSubject(
|
||||
optionalStatus.flatMap { L10n.Scene.Thread.title($0.author.displayNameWithFallback) }
|
||||
)
|
||||
|
||||
// bind fetcher domain
|
||||
context.authenticationService.activeMastodonAuthenticationBox
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] box in
|
||||
guard let self = self else { return }
|
||||
self.existStatusFetchedResultsController.domain.value = box?.domain
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
rootNode
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] rootNode in
|
||||
|
@ -79,8 +91,32 @@ class ThreadViewModel {
|
|||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
// descendantNodes
|
||||
|
||||
rootItem
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] rootItem in
|
||||
guard let self = self else { return }
|
||||
guard case let .root(objectID, _) = rootItem else { return }
|
||||
self.context.managedObjectContext.perform {
|
||||
guard let status = self.context.managedObjectContext.object(with: objectID) as? Status else {
|
||||
return
|
||||
}
|
||||
self.rootItemObserver = ManagedObjectObserver.observe(object: status)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(receiveCompletion: { _ in
|
||||
// do nothing
|
||||
}, receiveValue: { [weak self] change in
|
||||
guard let self = self else { return }
|
||||
switch change.changeType {
|
||||
case .delete:
|
||||
self.rootItem.value = nil
|
||||
default:
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
ancestorNodes
|
||||
.receive(on: DispatchQueue.main)
|
||||
.compactMap { [weak self] nodes -> [Item]? in
|
||||
|
@ -276,4 +312,3 @@ extension ThreadViewModel {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -117,6 +117,14 @@ extension APIService {
|
|||
}
|
||||
}()
|
||||
if let status = oldStatus {
|
||||
let homeTimelineIndexes = status.homeTimelineIndexes ?? Set()
|
||||
for homeTimelineIndex in homeTimelineIndexes {
|
||||
self.backgroundManagedObjectContext.delete(homeTimelineIndex)
|
||||
}
|
||||
let inNotifications = status.inNotifications ?? Set()
|
||||
for notification in inNotifications {
|
||||
self.backgroundManagedObjectContext.delete(notification)
|
||||
}
|
||||
self.backgroundManagedObjectContext.delete(status)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue