Merge pull request #161 from tootsuite/fix/compose

Fix thread and compose scene UI/UX issues
This commit is contained in:
CMK 2021-06-21 19:19:17 +08:00 committed by GitHub
commit 3060a579c5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 205 additions and 65 deletions

View File

@ -187,7 +187,7 @@
"blocking_warning": "You cant view Artbots profile\n until you unblock them.\nYour account looks like this to them.",
"blocked_warning": "You cant view Artbots 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": "%ss 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 (cant 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, youre 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 whats 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 cant be\nuploaded to Mastodon.",
"description_photo": "Describe photo for low vision people...",
"description_video": "Describe whats 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",

View File

@ -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>

View File

@ -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)))

View File

@ -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)
}

View File

@ -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 whats 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 cant 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 (cant 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, youre 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))
}

View File

@ -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 cant be
uploaded to Mastodon.";
"Scene.Compose.Attachment.DescriptionPhoto" = "Describe photo for low vision people...";
"Scene.Compose.Attachment.DescriptionVideo" = "Describe whats 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 whats 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 (cant 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, youre 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.";

View File

@ -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 cant be
uploaded to Mastodon.";
"Scene.Compose.Attachment.DescriptionPhoto" = "Describe photo for low vision people...";
"Scene.Compose.Attachment.DescriptionVideo" = "Describe whats 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 whats 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 (cant 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, youre 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.";

View File

@ -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),
])
}
}

View File

@ -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)
}

View File

@ -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(

View File

@ -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:

View File

@ -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))

View File

@ -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

View File

@ -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
}
}
}
}

View File

@ -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 {
}
}

View File

@ -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)
}
}