Merge branch 'feat/serverPicker' of https://github.com/tootsuite/mastodon-ios into feat/serverPicker

This commit is contained in:
jk234ert 2021-02-25 16:41:41 +08:00
commit ec96690ac9
31 changed files with 370 additions and 141 deletions

4
.gitignore vendored
View File

@ -118,4 +118,6 @@ xcuserdata
**/xcshareddata/WorkspaceSettings.xcsettings **/xcshareddata/WorkspaceSettings.xcsettings
# End of https://www.toptal.com/developers/gitignore/api/swift,swiftpm,xcode,cocoapods # End of https://www.toptal.com/developers/gitignore/api/swift,swiftpm,xcode,cocoapods
n
Localization/StringsConvertor/input
Localization/StringsConvertor/output

View File

@ -1,4 +0,0 @@
{
"NSCameraUsageDescription": "Used to take photo for toot",
"NSPhotoLibraryAddUsageDescription": "Used to save photo into the Photo Library"
}

View File

@ -1,2 +0,0 @@
"NSCameraUsageDescription" = "Used to take photo for toot";
"NSPhotoLibraryAddUsageDescription" = "Used to save photo into the Photo Library";

View File

@ -1,6 +1,14 @@
{ {
"common": { "common": {
"alerts": {}, "alerts": {
"sign_up_failure": {
"title": "Sign Up Failure"
},
"server_error": {
"title": "Server Error"
}
},
"controls": { "controls": {
"actions": { "actions": {
"add": "Add", "add": "Add",
@ -21,8 +29,9 @@
}, },
"status": { "status": {
"user_boosted": "%s boosted", "user_boosted": "%s boosted",
"content_warning": "content warning", "show_post": "Show Post",
"show_post": "Show Post" "status_content_warning": "content warning",
"media_content_warning": "Tap to reveal that may be sensitive"
}, },
"timeline": { "timeline": {
"load_more": "Load More" "load_more": "Load More"

View File

@ -145,7 +145,7 @@
DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BE825E4F5340051B173 /* SearchViewController.swift */; }; DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BE825E4F5340051B173 /* SearchViewController.swift */; };
DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */; }; DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */; };
DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */; }; DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */; };
DB9D6C0E25E4F9780051B173 /* MosaicImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C0D25E4F9780051B173 /* MosaicImageView.swift */; }; DB9D6C0E25E4F9780051B173 /* MosaicImageViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C0D25E4F9780051B173 /* MosaicImageViewContainer.swift */; };
DB9D6C2425E502C60051B173 /* MosaicImageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */; }; DB9D6C2425E502C60051B173 /* MosaicImageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */; };
DB9D6C2E25E504AC0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2D25E504AC0051B173 /* Attachment.swift */; }; DB9D6C2E25E504AC0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2D25E504AC0051B173 /* Attachment.swift */; };
DB9D6C3825E508BE0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C3725E508BE0051B173 /* Attachment.swift */; }; DB9D6C3825E508BE0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C3725E508BE0051B173 /* Attachment.swift */; };
@ -354,7 +354,7 @@
DB9D6BE825E4F5340051B173 /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = "<group>"; }; DB9D6BE825E4F5340051B173 /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = "<group>"; };
DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewController.swift; sourceTree = "<group>"; }; DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewController.swift; sourceTree = "<group>"; };
DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = "<group>"; }; DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = "<group>"; };
DB9D6C0D25E4F9780051B173 /* MosaicImageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MosaicImageView.swift; sourceTree = "<group>"; }; DB9D6C0D25E4F9780051B173 /* MosaicImageViewContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MosaicImageViewContainer.swift; sourceTree = "<group>"; };
DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MosaicImageViewModel.swift; sourceTree = "<group>"; }; DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MosaicImageViewModel.swift; sourceTree = "<group>"; };
DB9D6C2D25E504AC0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = "<group>"; }; DB9D6C2D25E504AC0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = "<group>"; };
DB9D6C3725E508BE0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = "<group>"; }; DB9D6C3725E508BE0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = "<group>"; };
@ -998,7 +998,7 @@
DB9D6C1325E4F97A0051B173 /* Container */ = { DB9D6C1325E4F97A0051B173 /* Container */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
DB9D6C0D25E4F9780051B173 /* MosaicImageView.swift */, DB9D6C0D25E4F9780051B173 /* MosaicImageViewContainer.swift */,
); );
path = Container; path = Container;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1438,7 +1438,7 @@
DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */, DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */,
DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */, DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */,
DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */, DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */,
DB9D6C0E25E4F9780051B173 /* MosaicImageView.swift in Sources */, DB9D6C0E25E4F9780051B173 /* MosaicImageViewContainer.swift in Sources */,
DB98338725C945ED00AD9700 /* Strings.swift in Sources */, DB98338725C945ED00AD9700 /* Strings.swift in Sources */,
DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */, DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */,
DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */, DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */,

View File

@ -46,6 +46,10 @@ extension SceneCoordinator {
case mastodonServerRules(viewModel: MastodonServerRulesViewModel) case mastodonServerRules(viewModel: MastodonServerRulesViewModel)
case alertController(alertController: UIAlertController) case alertController(alertController: UIAlertController)
#if DEBUG
case publicTimeline
#endif
} }
} }
@ -166,6 +170,12 @@ private extension SceneCoordinator {
) )
} }
viewController = alertController viewController = alertController
#if DEBUG
case .publicTimeline:
let _viewController = PublicTimelineViewController()
_viewController.viewModel = PublicTimelineViewModel(context: appContext)
viewController = _viewController
#endif
} }
setupDependency(for: viewController as? NeedsDependency) setupDependency(for: viewController as? NeedsDependency)

View File

@ -26,36 +26,32 @@ enum Item {
protocol StatusContentWarningAttribute { protocol StatusContentWarningAttribute {
var isStatusTextSensitive: Bool { get set } var isStatusTextSensitive: Bool { get set }
var isStatusSensitive: Bool { get set }
} }
extension Item { extension Item {
class StatusTimelineAttribute: Hashable, StatusContentWarningAttribute { class StatusTimelineAttribute: Hashable, StatusContentWarningAttribute {
var separatorLineStyle: SeparatorLineStyle = .indent var isStatusTextSensitive: Bool
var isStatusTextSensitive: Bool = false var isStatusSensitive: Bool
public init( public init(
separatorLineStyle: Item.StatusTimelineAttribute.SeparatorLineStyle = .indent, isStatusTextSensitive: Bool,
isStatusTextSensitive: Bool isStatusSensitive: Bool
) { ) {
self.separatorLineStyle = separatorLineStyle
self.isStatusTextSensitive = isStatusTextSensitive self.isStatusTextSensitive = isStatusTextSensitive
self.isStatusSensitive = isStatusSensitive
} }
static func == (lhs: Item.StatusTimelineAttribute, rhs: Item.StatusTimelineAttribute) -> Bool { static func == (lhs: Item.StatusTimelineAttribute, rhs: Item.StatusTimelineAttribute) -> Bool {
return lhs.separatorLineStyle == rhs.separatorLineStyle && return lhs.isStatusTextSensitive == rhs.isStatusTextSensitive &&
lhs.isStatusTextSensitive == rhs.isStatusTextSensitive lhs.isStatusSensitive == rhs.isStatusSensitive
} }
func hash(into hasher: inout Hasher) { func hash(into hasher: inout Hasher) {
hasher.combine(separatorLineStyle)
hasher.combine(isStatusTextSensitive) hasher.combine(isStatusTextSensitive)
hasher.combine(isStatusSensitive)
} }
enum SeparatorLineStyle {
case indent // alignment to name label
case expand // alignment to table view two edges
case normal // alignment to readable guideline
}
} }
} }

View File

@ -94,13 +94,18 @@ extension StatusSection {
// set text // set text
cell.statusView.activeTextLabel.config(content: (toot.reblog ?? toot).content) cell.statusView.activeTextLabel.config(content: (toot.reblog ?? toot).content)
// set content warning // set status text content warning
let isStatusTextSensitive = statusContentWarningAttribute?.isStatusTextSensitive ?? (toot.reblog ?? toot).sensitive let spoilerText = (toot.reblog ?? toot).spoilerText ?? ""
let isStatusTextSensitive = statusContentWarningAttribute?.isStatusTextSensitive ?? !spoilerText.isEmpty
cell.statusView.isStatusTextSensitive = isStatusTextSensitive
cell.statusView.updateContentWarningDisplay(isHidden: !isStatusTextSensitive) cell.statusView.updateContentWarningDisplay(isHidden: !isStatusTextSensitive)
cell.statusView.contentWarningTitle.text = (toot.reblog ?? toot).spoilerText.flatMap { spoilerText in cell.statusView.contentWarningTitle.text = {
guard !spoilerText.isEmpty else { return nil } if spoilerText.isEmpty {
return L10n.Common.Controls.Status.contentWarning + ": \(spoilerText)" return L10n.Common.Controls.Status.statusContentWarning
} ?? L10n.Common.Controls.Status.contentWarning } else {
return L10n.Common.Controls.Status.statusContentWarning + ": \(spoilerText)"
}
}()
// prepare media attachments // prepare media attachments
let mediaAttachments = Array((toot.reblog ?? toot).mediaAttachments ?? []).sorted { $0.index.compare($1.index) == .orderedAscending } let mediaAttachments = Array((toot.reblog ?? toot).mediaAttachments ?? []).sorted { $0.index.compare($1.index) == .orderedAscending }
@ -127,14 +132,14 @@ extension StatusSection {
}() }()
if mosiacImageViewModel.metas.count == 1 { if mosiacImageViewModel.metas.count == 1 {
let meta = mosiacImageViewModel.metas[0] let meta = mosiacImageViewModel.metas[0]
let imageView = cell.statusView.mosaicImageView.setupImageView(aspectRatio: meta.size, maxSize: imageViewMaxSize) let imageView = cell.statusView.statusMosaicImageView.setupImageView(aspectRatio: meta.size, maxSize: imageViewMaxSize)
imageView.af.setImage( imageView.af.setImage(
withURL: meta.url, withURL: meta.url,
placeholderImage: UIImage.placeholder(color: .systemFill), placeholderImage: UIImage.placeholder(color: .systemFill),
imageTransition: .crossDissolve(0.2) imageTransition: .crossDissolve(0.2)
) )
} else { } else {
let imageViews = cell.statusView.mosaicImageView.setupImageViews(count: mosiacImageViewModel.metas.count, maxHeight: imageViewMaxSize.height) let imageViews = cell.statusView.statusMosaicImageView.setupImageViews(count: mosiacImageViewModel.metas.count, maxHeight: imageViewMaxSize.height)
for (i, imageView) in imageViews.enumerated() { for (i, imageView) in imageViews.enumerated() {
let meta = mosiacImageViewModel.metas[i] let meta = mosiacImageViewModel.metas[i]
imageView.af.setImage( imageView.af.setImage(
@ -144,7 +149,10 @@ extension StatusSection {
) )
} }
} }
cell.statusView.mosaicImageView.isHidden = mosiacImageViewModel.metas.isEmpty cell.statusView.statusMosaicImageView.isHidden = mosiacImageViewModel.metas.isEmpty
let isStatusSensitive = statusContentWarningAttribute?.isStatusSensitive ?? (toot.reblog ?? toot).sensitive
cell.statusView.statusMosaicImageView.blurVisualEffectView.effect = isStatusSensitive ? MosaicImageViewContainer.blurVisualEffect : nil
cell.statusView.statusMosaicImageView.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0
// toolbar // toolbar
let replyCountTitle: String = { let replyCountTitle: String = {

View File

@ -9,26 +9,34 @@ import UIKit
// https://nshipster.com/swift-foundation-error-protocols/ // https://nshipster.com/swift-foundation-error-protocols/
extension UIAlertController { extension UIAlertController {
convenience init( convenience init(
_ error: Error, for error: Error,
title: String?,
preferredStyle: UIAlertController.Style preferredStyle: UIAlertController.Style
) { ) {
let title: String let _title: String
let message: String? let message: String?
if let error = error as? LocalizedError { if let error = error as? LocalizedError {
title = error.errorDescription ?? "Unknown Error" var messages: [String?] = []
message = [ if let title = title {
_title = title
messages.append(error.errorDescription)
} else {
_title = error.errorDescription ?? "Error"
}
messages.append(contentsOf: [
error.failureReason, error.failureReason,
error.recoverySuggestion error.recoverySuggestion
] ])
.compactMap { $0 } message = messages
.joined(separator: " ") .compactMap { $0 }
.joined(separator: " ")
} else { } else {
title = "Internal Error" _title = "Internal Error"
message = error.localizedDescription message = error.localizedDescription
} }
self.init( self.init(
title: title, title: _title,
message: message, message: message,
preferredStyle: preferredStyle preferredStyle: preferredStyle
) )

View File

@ -20,10 +20,6 @@ extension UIView {
return 1.0 / view.traitCollection.displayScale return 1.0 / view.traitCollection.displayScale
} }
static var floatyButtonBottomMargin: CGFloat {
return 16
}
} }
// MARK: - Convinience view appearance modification method // MARK: - Convinience view appearance modification method

View File

@ -12,6 +12,16 @@ import Foundation
internal enum L10n { internal enum L10n {
internal enum Common { internal enum Common {
internal enum Alerts {
internal enum ServerError {
/// Server Error
internal static let title = L10n.tr("Localizable", "Common.Alerts.ServerError.Title")
}
internal enum SignUpFailure {
/// Sign Up Failure
internal static let title = L10n.tr("Localizable", "Common.Alerts.SignUpFailure.Title")
}
}
internal enum Controls { internal enum Controls {
internal enum Actions { internal enum Actions {
/// Add /// Add
@ -46,10 +56,12 @@ internal enum L10n {
internal static let takePhoto = L10n.tr("Localizable", "Common.Controls.Actions.TakePhoto") internal static let takePhoto = L10n.tr("Localizable", "Common.Controls.Actions.TakePhoto")
} }
internal enum Status { internal enum Status {
/// content warning /// Tap to reveal that may be sensitive
internal static let contentWarning = L10n.tr("Localizable", "Common.Controls.Status.ContentWarning") internal static let mediaContentWarning = L10n.tr("Localizable", "Common.Controls.Status.MediaContentWarning")
/// Show Post /// Show Post
internal static let showPost = L10n.tr("Localizable", "Common.Controls.Status.ShowPost") internal static let showPost = L10n.tr("Localizable", "Common.Controls.Status.ShowPost")
/// content warning
internal static let statusContentWarning = L10n.tr("Localizable", "Common.Controls.Status.StatusContentWarning")
/// %@ boosted /// %@ boosted
internal static func userBoosted(_ p1: Any) -> String { internal static func userBoosted(_ p1: Any) -> String {
return L10n.tr("Localizable", "Common.Controls.Status.UserBoosted", String(describing: p1)) return L10n.tr("Localizable", "Common.Controls.Status.UserBoosted", String(describing: p1))

View File

@ -23,6 +23,13 @@ extension LoadMoreConfigurableTableViewContainer {
func handleScrollViewDidScroll(_ scrollView: UIScrollView) { func handleScrollViewDidScroll(_ scrollView: UIScrollView) {
guard scrollView === loadMoreConfigurableTableView else { return } guard scrollView === loadMoreConfigurableTableView else { return }
// check if current scroll position is the bottom of table
let contentOffsetY = loadMoreConfigurableTableView.contentOffset.y
let bottomVisiblePageContentOffsetY = loadMoreConfigurableTableView.contentSize.height - (1.5 * loadMoreConfigurableTableView.visibleSize.height)
guard contentOffsetY > bottomVisiblePageContentOffsetY else {
return
}
let cells = loadMoreConfigurableTableView.visibleCells.compactMap { $0 as? BottomLoaderTableViewCell } let cells = loadMoreConfigurableTableView.visibleCells.compactMap { $0 as? BottomLoaderTableViewCell }
guard let loaderTableViewCell = cells.first else { return } guard let loaderTableViewCell = cells.first else { return }

View File

@ -43,3 +43,39 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
} }
} }
extension StatusTableViewCellDelegate where Self: StatusProvider {
func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) {
}
func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) {
guard let diffableDataSource = self.tableViewDiffableDataSource else { return }
item(for: cell, indexPath: nil)
.receive(on: DispatchQueue.main)
.sink { [weak self] item in
guard let _ = self else { return }
guard let item = item else { return }
switch item {
case .homeTimelineIndex(_, let attribute):
attribute.isStatusSensitive = false
case .toot(_, let attribute):
attribute.isStatusSensitive = false
default:
return
}
var snapshot = diffableDataSource.snapshot()
snapshot.reloadItems([item])
UIView.animate(withDuration: 0.33) {
cell.statusView.statusMosaicImageView.blurVisualEffectView.effect = nil
cell.statusView.statusMosaicImageView.vibrancyVisualEffectView.alpha = 0.0
} completion: { _ in
diffableDataSource.apply(snapshot, animatingDifferences: false, completion: nil)
}
}
.store(in: &cell.disposeBag)
}
}

View File

@ -85,7 +85,7 @@ extension StatusProviderFacade {
os_log("%{public}s[%{public}ld], %{public}s: [Like] update local toot like status to: %s", ((#file as NSString).lastPathComponent), #line, #function, favoriteKind == .create ? "like" : "unlike") os_log("%{public}s[%{public}ld], %{public}s: [Like] update local toot like status to: %s", ((#file as NSString).lastPathComponent), #line, #function, favoriteKind == .create ? "like" : "unlike")
} receiveCompletion: { completion in } receiveCompletion: { completion in
switch completion { switch completion {
case .failure(let error): case .failure:
// TODO: handle error // TODO: handle error
break break
case .finished: case .finished:

View File

@ -1,3 +1,5 @@
"Common.Alerts.ServerError.Title" = "Server Error";
"Common.Alerts.SignUpFailure.Title" = "Sign Up Failure";
"Common.Controls.Actions.Add" = "Add"; "Common.Controls.Actions.Add" = "Add";
"Common.Controls.Actions.Cancel" = "Cancel"; "Common.Controls.Actions.Cancel" = "Cancel";
"Common.Controls.Actions.Confirm" = "Confirm"; "Common.Controls.Actions.Confirm" = "Confirm";
@ -13,8 +15,9 @@
"Common.Controls.Actions.SignIn" = "Sign in"; "Common.Controls.Actions.SignIn" = "Sign in";
"Common.Controls.Actions.SignUp" = "Sign up"; "Common.Controls.Actions.SignUp" = "Sign up";
"Common.Controls.Actions.TakePhoto" = "Take photo"; "Common.Controls.Actions.TakePhoto" = "Take photo";
"Common.Controls.Status.ContentWarning" = "content warning"; "Common.Controls.Status.MediaContentWarning" = "Tap to reveal that may be sensitive";
"Common.Controls.Status.ShowPost" = "Show Post"; "Common.Controls.Status.ShowPost" = "Show Post";
"Common.Controls.Status.StatusContentWarning" = "content warning";
"Common.Controls.Status.UserBoosted" = "%@ boosted"; "Common.Controls.Status.UserBoosted" = "%@ boosted";
"Common.Controls.Timeline.LoadMore" = "Load More"; "Common.Controls.Timeline.LoadMore" = "Load More";
"Common.Countable.Photo.Multiple" = "photos"; "Common.Countable.Photo.Multiple" = "photos";

View File

@ -195,7 +195,7 @@ extension AuthenticationViewController {
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] error in .sink { [weak self] error in
guard let self = self else { return } guard let self = self else { return }
let alertController = UIAlertController(error, preferredStyle: .alert) let alertController = UIAlertController(for: error, title: nil, preferredStyle: .alert)
let okAction = UIAlertAction(title: "OK", style: .default, handler: nil) let okAction = UIAlertAction(title: "OK", style: .default, handler: nil)
alertController.addAction(okAction) alertController.addAction(okAction)
self.coordinator.present( self.coordinator.present(

View File

@ -398,7 +398,7 @@ extension MastodonRegisterViewController {
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] error in .sink { [weak self] error in
guard let self = self else { return } guard let self = self else { return }
let alertController = UIAlertController(error, preferredStyle: .alert) let alertController = UIAlertController(for: error, title: "Sign Up Failure", preferredStyle: .alert)
let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil) let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil)
alertController.addAction(okAction) alertController.addAction(okAction)
self.coordinator.present( self.coordinator.present(

View File

@ -17,6 +17,10 @@ extension HomeTimelineViewController {
identifier: nil, identifier: nil,
options: .displayInline, options: .displayInline,
children: [ children: [
UIAction(title: "Show Public Timeline", image: UIImage(systemName: "list.dash"), attributes: []) { [weak self] action in
guard let self = self else { return }
self.showPublicTimelineAction(action)
},
UIAction(title: "Sign Out", image: UIImage(systemName: "escape"), attributes: .destructive) { [weak self] action in UIAction(title: "Sign Out", image: UIImage(systemName: "escape"), attributes: .destructive) { [weak self] action in
guard let self = self else { return } guard let self = self else { return }
self.signOutAction(action) self.signOutAction(action)
@ -29,6 +33,10 @@ extension HomeTimelineViewController {
extension HomeTimelineViewController { extension HomeTimelineViewController {
@objc private func showPublicTimelineAction(_ sender: UIAction) {
coordinator.present(scene: .publicTimeline, from: self, transition: .show)
}
@objc private func signOutAction(_ sender: UIAction) { @objc private func signOutAction(_ sender: UIAction) {
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
return return

View File

@ -175,23 +175,17 @@ extension HomeTimelineViewController {
// MARK: - UIScrollViewDelegate // MARK: - UIScrollViewDelegate
extension HomeTimelineViewController { extension HomeTimelineViewController {
func scrollViewDidScroll(_ scrollView: UIScrollView) { func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard scrollView === tableView else { return } handleScrollViewDidScroll(scrollView)
let cells = tableView.visibleCells.compactMap { $0 as? TimelineBottomLoaderTableViewCell }
guard let loaderTableViewCell = cells.first else { return }
if let tabBar = tabBarController?.tabBar, let window = view.window {
let loaderTableViewCellFrameInWindow = tableView.convert(loaderTableViewCell.frame, to: nil)
let windowHeight = window.frame.height
let loaderAppear = (loaderTableViewCellFrameInWindow.origin.y + 0.8 * loaderTableViewCell.frame.height) < (windowHeight - tabBar.frame.height)
if loaderAppear {
viewModel.loadoldestStateMachine.enter(HomeTimelineViewModel.LoadOldestState.Loading.self)
}
} else {
viewModel.loadoldestStateMachine.enter(HomeTimelineViewModel.LoadOldestState.Loading.self)
}
} }
} }
extension HomeTimelineViewController: LoadMoreConfigurableTableViewContainer {
typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell
typealias LoadingState = HomeTimelineViewModel.LoadOldestState.Loading
var loadMoreConfigurableTableView: UITableView { return tableView }
var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.loadoldestStateMachine }
}
// MARK: - UITableViewDelegate // MARK: - UITableViewDelegate
extension HomeTimelineViewController: UITableViewDelegate { extension HomeTimelineViewController: UITableViewDelegate {
@ -206,14 +200,7 @@ extension HomeTimelineViewController: UITableViewDelegate {
return ceil(frame.height) return ceil(frame.height)
} }
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
if let cell = cell as? StatusTableViewCell {
DispatchQueue.main.async {
cell.statusView.drawContentWarningImageView()
}
}
}
} }
// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate // MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate
@ -233,7 +220,7 @@ extension HomeTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate
viewModel.loadMiddleSateMachineList viewModel.loadMiddleSateMachineList
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] ids in .sink { [weak self] ids in
guard let self = self else { return } guard let _ = self else { return }
if let stateMachine = ids[upperTimelineIndexObjectID] { if let stateMachine = ids[upperTimelineIndexObjectID] {
guard let state = stateMachine.currentState else { guard let state = stateMachine.currentState else {
assertionFailure() assertionFailure()

View File

@ -83,23 +83,24 @@ extension HomeTimelineViewModel: NSFetchedResultsControllerDelegate {
var newTimelineItems: [Item] = [] var newTimelineItems: [Item] = []
for (i, timelineIndex) in timelineIndexes.enumerated() { for (i, timelineIndex) in timelineIndexes.enumerated() {
let attribute = oldSnapshotAttributeDict[timelineIndex.objectID] ?? Item.StatusTimelineAttribute(isStatusTextSensitive: timelineIndex.toot.sensitive) let toot = timelineIndex.toot.reblog ?? timelineIndex.toot
let isStatusTextSensitive: Bool = {
guard let spoilerText = toot.spoilerText, !spoilerText.isEmpty else { return false }
return true
}()
let attribute = oldSnapshotAttributeDict[timelineIndex.objectID] ?? Item.StatusTimelineAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: toot.sensitive)
// append new item into snapshot // append new item into snapshot
newTimelineItems.append(.homeTimelineIndex(objectID: timelineIndex.objectID, attribute: attribute)) newTimelineItems.append(.homeTimelineIndex(objectID: timelineIndex.objectID, attribute: attribute))
let isLast = i == timelineIndexes.count - 1 let isLast = i == timelineIndexes.count - 1
switch (isLast, timelineIndex.hasMore) { switch (isLast, timelineIndex.hasMore) {
case (true, false):
attribute.separatorLineStyle = .normal
case (false, true): case (false, true):
attribute.separatorLineStyle = .expand
newTimelineItems.append(.homeMiddleLoader(upperTimelineIndexAnchorObjectID: timelineIndex.objectID)) newTimelineItems.append(.homeMiddleLoader(upperTimelineIndexAnchorObjectID: timelineIndex.objectID))
case (true, true): case (true, true):
attribute.separatorLineStyle = .normal
shouldAddBottomLoader = true shouldAddBottomLoader = true
case (false, false): default:
attribute.separatorLineStyle = .indent break
} }
} // end for } // end for

View File

@ -112,7 +112,7 @@ extension MainTabBarController {
case .implicit: case .implicit:
break break
case .explicit: case .explicit:
let alertController = UIAlertController(error, preferredStyle: .alert) let alertController = UIAlertController(for: error, title: nil, preferredStyle: .alert)
let okAction = UIAlertAction(title: "OK", style: .default, handler: nil) let okAction = UIAlertAction(title: "OK", style: .default, handler: nil)
alertController.addAction(okAction) alertController.addAction(okAction)
coordinator.present( coordinator.present(

View File

@ -13,7 +13,7 @@ import GameplayKit
import os.log import os.log
import UIKit import UIKit
final class PublicTimelineViewController: UIViewController, NeedsDependency, StatusTableViewCellDelegate { final class PublicTimelineViewController: UIViewController, NeedsDependency {
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
@ -42,6 +42,7 @@ extension PublicTimelineViewController {
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
title = "Public"
view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
tableView.refreshControl = refreshControl tableView.refreshControl = refreshControl
@ -202,3 +203,6 @@ extension PublicTimelineViewController: TimelineMiddleLoaderTableViewCellDelegat
} }
} }
} }
// MARK: - StatusTableViewCellDelegate
extension PublicTimelineViewController: StatusTableViewCellDelegate { }

View File

@ -58,7 +58,12 @@ extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate {
var items = [Item]() var items = [Item]()
for (_, toot) in indexTootTuples { for (_, toot) in indexTootTuples {
let attribute = oldSnapshotAttributeDict[toot.objectID] ?? Item.StatusTimelineAttribute(isStatusTextSensitive: toot.sensitive) let targetToot = toot.reblog ?? toot
let isStatusTextSensitive: Bool = {
guard let spoilerText = targetToot.spoilerText, !spoilerText.isEmpty else { return false }
return true
}()
let attribute = oldSnapshotAttributeDict[toot.objectID] ?? Item.StatusTimelineAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: targetToot.sensitive)
items.append(Item.toot(objectID: toot.objectID, attribute: attribute)) items.append(Item.toot(objectID: toot.objectID, attribute: attribute))
if tootIDsWhichHasGap.contains(toot.id) { if tootIDsWhichHasGap.contains(toot.id) {
items.append(Item.publicMiddleLoader(tootID: toot.id)) items.append(Item.publicMiddleLoader(tootID: toot.id))

View File

@ -1,5 +1,5 @@
// //
// MosaicImageView.swift // MosaicImageViewContainer.swift
// Mastodon // Mastodon
// //
// Created by Cirno MainasuK on 2021-2-23. // Created by Cirno MainasuK on 2021-2-23.
@ -9,32 +9,44 @@ import os.log
import func AVFoundation.AVMakeRect import func AVFoundation.AVMakeRect
import UIKit import UIKit
protocol MosaicImageViewPresentable: class { protocol MosaicImageViewContainerPresentable: class {
var mosaicImageView: MosaicImageView { get } var mosaicImageViewContainer: MosaicImageViewContainer { get }
} }
protocol MosaicImageViewDelegate: class { protocol MosaicImageViewContainerDelegate: class {
func mosaicImageView(_ mosaicImageView: MosaicImageView, didTapImageView imageView: UIImageView, atIndex index: Int) func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int)
func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView)
} }
final class MosaicImageView: UIView { final class MosaicImageViewContainer: UIView {
static let cornerRadius: CGFloat = 4 static let cornerRadius: CGFloat = 4
static let blurVisualEffect = UIBlurEffect(style: .systemUltraThinMaterial)
weak var delegate: MosaicImageViewDelegate? weak var delegate: MosaicImageViewContainerDelegate?
let container = UIStackView() let container = UIStackView()
var imageViews = [UIImageView]() { var imageViews: [UIImageView] = [] {
didSet { didSet {
imageViews.forEach { imageView in imageViews.forEach { imageView in
imageView.isUserInteractionEnabled = true imageView.isUserInteractionEnabled = true
let tapGesture = UITapGestureRecognizer.singleTapGestureRecognizer let tapGesture = UITapGestureRecognizer.singleTapGestureRecognizer
tapGesture.addTarget(self, action: #selector(MosaicImageView.photoTapGestureRecognizerHandler(_:))) tapGesture.addTarget(self, action: #selector(MosaicImageViewContainer.photoTapGestureRecognizerHandler(_:)))
imageView.addGestureRecognizer(tapGesture) imageView.addGestureRecognizer(tapGesture)
} }
} }
} }
let blurVisualEffectView = UIVisualEffectView(effect: MosaicImageViewContainer.blurVisualEffect)
let vibrancyVisualEffectView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: MosaicImageViewContainer.blurVisualEffect))
let contentWarningLabel: UILabel = {
let label = UILabel()
label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15))
label.text = L10n.Common.Controls.Status.mediaContentWarning
label.textAlignment = .center
return label
}()
private var containerHeightLayoutConstraint: NSLayoutConstraint! private var containerHeightLayoutConstraint: NSLayoutConstraint!
override init(frame: CGRect) { override init(frame: CGRect) {
@ -49,10 +61,12 @@ final class MosaicImageView: UIView {
} }
extension MosaicImageView { extension MosaicImageViewContainer {
private func _init() { private func _init() {
container.translatesAutoresizingMaskIntoConstraints = false container.translatesAutoresizingMaskIntoConstraints = false
container.axis = .horizontal
container.distribution = .fillEqually
addSubview(container) addSubview(container)
containerHeightLayoutConstraint = container.heightAnchor.constraint(equalToConstant: 162).priority(.required - 1) containerHeightLayoutConstraint = container.heightAnchor.constraint(equalToConstant: 162).priority(.required - 1)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
@ -63,13 +77,37 @@ extension MosaicImageView {
containerHeightLayoutConstraint containerHeightLayoutConstraint
]) ])
container.axis = .horizontal // add blur visual effect view in the setup method
container.distribution = .fillEqually blurVisualEffectView.layer.masksToBounds = true
blurVisualEffectView.layer.cornerRadius = MosaicImageViewContainer.cornerRadius
blurVisualEffectView.layer.cornerCurve = .continuous
vibrancyVisualEffectView.translatesAutoresizingMaskIntoConstraints = false
blurVisualEffectView.contentView.addSubview(vibrancyVisualEffectView)
NSLayoutConstraint.activate([
vibrancyVisualEffectView.topAnchor.constraint(equalTo: blurVisualEffectView.topAnchor),
vibrancyVisualEffectView.leadingAnchor.constraint(equalTo: blurVisualEffectView.leadingAnchor),
vibrancyVisualEffectView.trailingAnchor.constraint(equalTo: blurVisualEffectView.trailingAnchor),
vibrancyVisualEffectView.bottomAnchor.constraint(equalTo: blurVisualEffectView.bottomAnchor),
])
contentWarningLabel.translatesAutoresizingMaskIntoConstraints = false
vibrancyVisualEffectView.contentView.addSubview(contentWarningLabel)
NSLayoutConstraint.activate([
contentWarningLabel.leadingAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.layoutMarginsGuide.leadingAnchor),
contentWarningLabel.trailingAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.layoutMarginsGuide.trailingAnchor),
contentWarningLabel.centerYAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.centerYAnchor),
])
blurVisualEffectView.isUserInteractionEnabled = true
let tapGesture = UITapGestureRecognizer.singleTapGestureRecognizer
tapGesture.addTarget(self, action: #selector(MosaicImageViewContainer.visualEffectViewTapGestureRecognizerHandler(_:)))
blurVisualEffectView.addGestureRecognizer(tapGesture)
} }
} }
extension MosaicImageView { extension MosaicImageViewContainer {
func reset() { func reset() {
container.arrangedSubviews.forEach { subview in container.arrangedSubviews.forEach { subview in
@ -79,6 +117,9 @@ extension MosaicImageView {
container.subviews.forEach { subview in container.subviews.forEach { subview in
subview.removeFromSuperview() subview.removeFromSuperview()
} }
blurVisualEffectView.removeFromSuperview()
blurVisualEffectView.effect = MosaicImageViewContainer.blurVisualEffect
vibrancyVisualEffectView.alpha = 1.0
imageViews = [] imageViews = []
container.spacing = 1 container.spacing = 1
@ -99,7 +140,8 @@ extension MosaicImageView {
let imageView = UIImageView() let imageView = UIImageView()
imageViews.append(imageView) imageViews.append(imageView)
imageView.layer.masksToBounds = true imageView.layer.masksToBounds = true
imageView.layer.cornerRadius = MosaicImageView.cornerRadius imageView.layer.cornerRadius = MosaicImageViewContainer.cornerRadius
imageView.layer.cornerCurve = .continuous
imageView.contentMode = .scaleAspectFill imageView.contentMode = .scaleAspectFill
imageView.translatesAutoresizingMaskIntoConstraints = false imageView.translatesAutoresizingMaskIntoConstraints = false
@ -112,6 +154,15 @@ extension MosaicImageView {
]) ])
containerHeightLayoutConstraint.constant = floor(rect.height) containerHeightLayoutConstraint.constant = floor(rect.height)
containerHeightLayoutConstraint.isActive = true containerHeightLayoutConstraint.isActive = true
blurVisualEffectView.translatesAutoresizingMaskIntoConstraints = false
addSubview(blurVisualEffectView)
NSLayoutConstraint.activate([
blurVisualEffectView.topAnchor.constraint(equalTo: imageView.topAnchor),
blurVisualEffectView.leadingAnchor.constraint(equalTo: imageView.leadingAnchor),
blurVisualEffectView.trailingAnchor.constraint(equalTo: imageView.trailingAnchor),
blurVisualEffectView.bottomAnchor.constraint(equalTo: imageView.bottomAnchor),
])
return imageView return imageView
} }
@ -142,7 +193,7 @@ extension MosaicImageView {
self.imageViews.append(contentsOf: imageViews) self.imageViews.append(contentsOf: imageViews)
imageViews.forEach { imageView in imageViews.forEach { imageView in
imageView.layer.masksToBounds = true imageView.layer.masksToBounds = true
imageView.layer.cornerRadius = MosaicImageView.cornerRadius imageView.layer.cornerRadius = MosaicImageViewContainer.cornerRadius
imageView.layer.cornerCurve = .continuous imageView.layer.cornerCurve = .continuous
imageView.contentMode = .scaleAspectFill imageView.contentMode = .scaleAspectFill
} }
@ -191,18 +242,34 @@ extension MosaicImageView {
} }
} }
blurVisualEffectView.translatesAutoresizingMaskIntoConstraints = false
addSubview(blurVisualEffectView)
NSLayoutConstraint.activate([
blurVisualEffectView.topAnchor.constraint(equalTo: container.topAnchor),
blurVisualEffectView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
blurVisualEffectView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
blurVisualEffectView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
])
return imageViews return imageViews
} }
} }
extension MosaicImageView { extension MosaicImageViewContainer {
@objc private func visualEffectViewTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
delegate?.mosaicImageViewContainer(self, didTapContentWarningVisualEffectView: blurVisualEffectView)
}
@objc private func photoTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { @objc private func photoTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) {
guard let imageView = sender.view as? UIImageView else { return } guard let imageView = sender.view as? UIImageView else { return }
guard let index = imageViews.firstIndex(of: imageView) else { return } guard let index = imageViews.firstIndex(of: imageView) else { return }
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: tap photo at index: %ld", ((#file as NSString).lastPathComponent), #line, #function, index) os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: tap photo at index: %ld", ((#file as NSString).lastPathComponent), #line, #function, index)
delegate?.mosaicImageView(self, didTapImageView: imageView, atIndex: index) delegate?.mosaicImageViewContainer(self, didTapImageView: imageView, atIndex: index)
} }
} }
#if DEBUG && canImport(SwiftUI) #if DEBUG && canImport(SwiftUI)
@ -218,7 +285,7 @@ struct MosaicImageView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
Group { Group {
UIViewPreview(width: 375) { UIViewPreview(width: 375) {
let view = MosaicImageView() let view = MosaicImageViewContainer()
let image = images[3] let image = images[3]
let imageView = view.setupImageView( let imageView = view.setupImageView(
aspectRatio: image.size, aspectRatio: image.size,
@ -230,7 +297,7 @@ struct MosaicImageView_Previews: PreviewProvider {
.previewLayout(.fixed(width: 375, height: 400)) .previewLayout(.fixed(width: 375, height: 400))
.previewDisplayName("Portrait - one image") .previewDisplayName("Portrait - one image")
UIViewPreview(width: 375) { UIViewPreview(width: 375) {
let view = MosaicImageView() let view = MosaicImageViewContainer()
let image = images[1] let image = images[1]
let imageView = view.setupImageView( let imageView = view.setupImageView(
aspectRatio: image.size, aspectRatio: image.size,
@ -245,7 +312,7 @@ struct MosaicImageView_Previews: PreviewProvider {
.previewLayout(.fixed(width: 375, height: 400)) .previewLayout(.fixed(width: 375, height: 400))
.previewDisplayName("Landscape - one image") .previewDisplayName("Landscape - one image")
UIViewPreview(width: 375) { UIViewPreview(width: 375) {
let view = MosaicImageView() let view = MosaicImageViewContainer()
let images = self.images.prefix(2) let images = self.images.prefix(2)
let imageViews = view.setupImageViews(count: images.count, maxHeight: 162) let imageViews = view.setupImageViews(count: images.count, maxHeight: 162)
for (i, imageView) in imageViews.enumerated() { for (i, imageView) in imageViews.enumerated() {
@ -256,7 +323,7 @@ struct MosaicImageView_Previews: PreviewProvider {
.previewLayout(.fixed(width: 375, height: 200)) .previewLayout(.fixed(width: 375, height: 200))
.previewDisplayName("two image") .previewDisplayName("two image")
UIViewPreview(width: 375) { UIViewPreview(width: 375) {
let view = MosaicImageView() let view = MosaicImageViewContainer()
let images = self.images.prefix(3) let images = self.images.prefix(3)
let imageViews = view.setupImageViews(count: images.count, maxHeight: 162) let imageViews = view.setupImageViews(count: images.count, maxHeight: 162)
for (i, imageView) in imageViews.enumerated() { for (i, imageView) in imageViews.enumerated() {
@ -267,7 +334,7 @@ struct MosaicImageView_Previews: PreviewProvider {
.previewLayout(.fixed(width: 375, height: 200)) .previewLayout(.fixed(width: 375, height: 200))
.previewDisplayName("three image") .previewDisplayName("three image")
UIViewPreview(width: 375) { UIViewPreview(width: 375) {
let view = MosaicImageView() let view = MosaicImageViewContainer()
let images = self.images.prefix(4) let images = self.images.prefix(4)
let imageViews = view.setupImageViews(count: images.count, maxHeight: 162) let imageViews = view.setupImageViews(count: images.count, maxHeight: 162)
for (i, imageView) in imageViews.enumerated() { for (i, imageView) in imageViews.enumerated() {

View File

@ -22,6 +22,7 @@ final class StatusView: UIView {
static let contentWarningBlurRadius: CGFloat = 12 static let contentWarningBlurRadius: CGFloat = 12
weak var delegate: StatusViewDelegate? weak var delegate: StatusViewDelegate?
var isStatusTextSensitive = false
let headerContainerStackView = UIStackView() let headerContainerStackView = UIStackView()
@ -88,7 +89,7 @@ final class StatusView: UIView {
let label = UILabel() let label = UILabel()
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .regular)) label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .regular))
label.textColor = Asset.Colors.Label.primary.color label.textColor = Asset.Colors.Label.primary.color
label.text = L10n.Common.Controls.Status.contentWarning label.text = L10n.Common.Controls.Status.statusContentWarning
return label return label
}() }()
let contentWarningActionButton: UIButton = { let contentWarningActionButton: UIButton = {
@ -98,12 +99,12 @@ final class StatusView: UIView {
button.setTitle(L10n.Common.Controls.Status.showPost, for: .normal) button.setTitle(L10n.Common.Controls.Status.showPost, for: .normal)
return button return button
}() }()
let mosaicImageView = MosaicImageView() let statusMosaicImageView = MosaicImageViewContainer()
// do not use visual effect view due to we blur text only without background // do not use visual effect view due to we blur text only without background
let contentWarningBlurContentImageView: UIImageView = { let contentWarningBlurContentImageView: UIImageView = {
let imageView = UIImageView() let imageView = UIImageView()
imageView.backgroundColor = .secondarySystemGroupedBackground imageView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color
imageView.layer.masksToBounds = false imageView.layer.masksToBounds = false
return imageView return imageView
}() }()
@ -126,6 +127,15 @@ final class StatusView: UIView {
super.init(coder: coder) super.init(coder: coder)
_init() _init()
} }
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
// update blur image when interface style changed
if previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle {
drawContentWarningImageView()
}
}
} }
@ -247,7 +257,7 @@ extension StatusView {
]) ])
statusContentWarningContainerStackView.addArrangedSubview(contentWarningTitle) statusContentWarningContainerStackView.addArrangedSubview(contentWarningTitle)
statusContentWarningContainerStackView.addArrangedSubview(contentWarningActionButton) statusContentWarningContainerStackView.addArrangedSubview(contentWarningActionButton)
statusContainerStackView.addArrangedSubview(mosaicImageView) statusContainerStackView.addArrangedSubview(statusMosaicImageView)
// action toolbar container // action toolbar container
@ -255,7 +265,7 @@ extension StatusView {
actionToolbarContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical) actionToolbarContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
headerContainerStackView.isHidden = true headerContainerStackView.isHidden = true
mosaicImageView.isHidden = true statusMosaicImageView.isHidden = true
contentWarningBlurContentImageView.isHidden = true contentWarningBlurContentImageView.isHidden = true
statusContentWarningContainerStackView.isHidden = true statusContentWarningContainerStackView.isHidden = true
statusContentWarningContainerStackViewBottomLayoutConstraint.isActive = false statusContentWarningContainerStackViewBottomLayoutConstraint.isActive = false
@ -272,7 +282,9 @@ extension StatusView {
} }
func drawContentWarningImageView() { func drawContentWarningImageView() {
guard activeTextLabel.frame != .zero, let text = activeTextLabel.text, !text.isEmpty else { guard activeTextLabel.frame != .zero,
isStatusTextSensitive,
let text = activeTextLabel.text, !text.isEmpty else {
cleanUpContentWarning() cleanUpContentWarning()
return return
} }
@ -331,7 +343,7 @@ struct StatusView_Previews: PreviewProvider {
} }
.previewLayout(.fixed(width: 375, height: 200)) .previewLayout(.fixed(width: 375, height: 200))
UIViewPreview(width: 375) { UIViewPreview(width: 375) {
let statusView = StatusView() let statusView = StatusView(frame: CGRect(x: 0, y: 0, width: 375, height: 500))
statusView.configure( statusView.configure(
with: AvatarConfigurableViewConfiguration( with: AvatarConfigurableViewConfiguration(
avatarImageURL: nil, avatarImageURL: nil,
@ -339,9 +351,20 @@ struct StatusView_Previews: PreviewProvider {
) )
) )
statusView.headerContainerStackView.isHidden = false statusView.headerContainerStackView.isHidden = false
statusView.isStatusTextSensitive = true
statusView.setNeedsLayout()
statusView.layoutIfNeeded()
statusView.drawContentWarningImageView()
statusView.updateContentWarningDisplay(isHidden: false)
let images = MosaicImageView_Previews.images
let imageViews = statusView.statusMosaicImageView.setupImageViews(count: 4, maxHeight: 162)
for (i, imageView) in imageViews.enumerated() {
imageView.image = images[i]
}
statusView.statusMosaicImageView.isHidden = false
return statusView return statusView
} }
.previewLayout(.fixed(width: 375, height: 200)) .previewLayout(.fixed(width: 375, height: 380))
} }
} }

View File

@ -14,10 +14,14 @@ import Combine
protocol StatusTableViewCellDelegate: class { protocol StatusTableViewCellDelegate: class {
func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton)
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton)
func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int)
func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView)
} }
final class StatusTableViewCell: UITableViewCell { final class StatusTableViewCell: UITableViewCell {
static let bottomPaddingHeight: CGFloat = 10
weak var delegate: StatusTableViewCellDelegate? weak var delegate: StatusTableViewCellDelegate?
@ -28,6 +32,7 @@ final class StatusTableViewCell: UITableViewCell {
override func prepareForReuse() { override func prepareForReuse() {
super.prepareForReuse() super.prepareForReuse()
statusView.isStatusTextSensitive = false
statusView.cleanUpContentWarning() statusView.cleanUpContentWarning()
disposeBag.removeAll() disposeBag.removeAll()
observations.removeAll() observations.removeAll()
@ -43,6 +48,13 @@ final class StatusTableViewCell: UITableViewCell {
_init() _init()
} }
override func layoutSubviews() {
super.layoutSubviews()
DispatchQueue.main.async {
self.statusView.drawContentWarningImageView()
}
}
} }
extension StatusTableViewCell { extension StatusTableViewCell {
@ -50,6 +62,7 @@ extension StatusTableViewCell {
private func _init() { private func _init() {
selectionStyle = .none selectionStyle = .none
backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color
statusView.contentWarningBlurContentImageView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color
statusView.translatesAutoresizingMaskIntoConstraints = false statusView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(statusView) contentView.addSubview(statusView)
@ -67,12 +80,13 @@ extension StatusTableViewCell {
bottomPaddingView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), bottomPaddingView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
bottomPaddingView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), bottomPaddingView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
bottomPaddingView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), bottomPaddingView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
bottomPaddingView.heightAnchor.constraint(equalToConstant: 10).priority(.defaultHigh), bottomPaddingView.heightAnchor.constraint(equalToConstant: StatusTableViewCell.bottomPaddingHeight).priority(.defaultHigh),
]) ])
bottomPaddingView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
statusView.delegate = self statusView.delegate = self
statusView.statusMosaicImageView.delegate = self
statusView.actionToolbarContainer.delegate = self statusView.actionToolbarContainer.delegate = self
bottomPaddingView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
} }
} }
@ -84,6 +98,19 @@ extension StatusTableViewCell: StatusViewDelegate {
} }
} }
// MARK: - MosaicImageViewDelegate
extension StatusTableViewCell: MosaicImageViewContainerDelegate {
func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) {
delegate?.statusTableViewCell(self, mosaicImageViewContainer: mosaicImageViewContainer, didTapImageView: imageView, atIndex: index)
}
func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) {
delegate?.statusTableViewCell(self, mosaicImageViewContainer: mosaicImageViewContainer, didTapContentWarningVisualEffectView: visualEffectView)
}
}
// MARK: - ActionToolbarContainerDelegate // MARK: - ActionToolbarContainerDelegate
extension StatusTableViewCell: ActionToolbarContainerDelegate { extension StatusTableViewCell: ActionToolbarContainerDelegate {
func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, replayButtonDidPressed sender: UIButton) { func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, replayButtonDidPressed sender: UIButton) {

View File

@ -17,3 +17,20 @@ final class TimelineBottomLoaderTableViewCell: TimelineLoaderTableViewCell {
activityIndicatorView.startAnimating() activityIndicatorView.startAnimating()
} }
} }
#if canImport(SwiftUI) && DEBUG
import SwiftUI
struct TimelineBottomLoaderTableViewCell_Previews: PreviewProvider {
static var previews: some View {
UIViewPreview(width: 375) {
TimelineBottomLoaderTableViewCell()
}
.previewLayout(.fixed(width: 375, height: 100))
}
}
#endif

View File

@ -10,7 +10,9 @@ import Combine
class TimelineLoaderTableViewCell: UITableViewCell { class TimelineLoaderTableViewCell: UITableViewCell {
static let cellHeight: CGFloat = 48 static let cellHeight: CGFloat = 44 + TimelineLoaderTableViewCell.extraTopPadding + TimelineLoaderTableViewCell.bottomPadding
static let extraTopPadding: CGFloat = 0 // the status cell already has 10pt bottom padding
static let bottomPadding: CGFloat = StatusTableViewCell.bottomPaddingHeight + TimelineLoaderTableViewCell.extraTopPadding // make balance
var disposeBag = Set<AnyCancellable>() var disposeBag = Set<AnyCancellable>()
@ -50,18 +52,18 @@ class TimelineLoaderTableViewCell: UITableViewCell {
loadMoreButton.translatesAutoresizingMaskIntoConstraints = false loadMoreButton.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(loadMoreButton) contentView.addSubview(loadMoreButton)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
loadMoreButton.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), loadMoreButton.topAnchor.constraint(equalTo: contentView.topAnchor, constant: TimelineLoaderTableViewCell.extraTopPadding),
loadMoreButton.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), loadMoreButton.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
contentView.readableContentGuide.trailingAnchor.constraint(equalTo: loadMoreButton.trailingAnchor), contentView.readableContentGuide.trailingAnchor.constraint(equalTo: loadMoreButton.trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: loadMoreButton.bottomAnchor, constant: 8), contentView.bottomAnchor.constraint(equalTo: loadMoreButton.bottomAnchor, constant: TimelineLoaderTableViewCell.bottomPadding),
loadMoreButton.heightAnchor.constraint(equalToConstant: TimelineLoaderTableViewCell.cellHeight - 2 * 8).priority(.defaultHigh), loadMoreButton.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh),
]) ])
activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false
addSubview(activityIndicatorView) addSubview(activityIndicatorView)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
activityIndicatorView.centerXAnchor.constraint(equalTo: centerXAnchor), activityIndicatorView.centerXAnchor.constraint(equalTo: loadMoreButton.centerXAnchor),
activityIndicatorView.centerYAnchor.constraint(equalTo: centerYAnchor), activityIndicatorView.centerYAnchor.constraint(equalTo: loadMoreButton.centerYAnchor),
]) ])
loadMoreButton.isHidden = true loadMoreButton.isHidden = true

View File

@ -23,16 +23,6 @@ final class TimelineMiddleLoaderTableViewCell: TimelineLoaderTableViewCell {
backgroundColor = .clear backgroundColor = .clear
let separatorLine = UIView.separatorLine
separatorLine.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(separatorLine)
NSLayoutConstraint.activate([
separatorLine.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
contentView.trailingAnchor.constraint(equalTo: separatorLine.trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: separatorLine.bottomAnchor),
separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: separatorLine))
])
loadMoreButton.isHidden = false loadMoreButton.isHidden = false
loadMoreButton.setImage(Asset.Arrows.arrowTriangle2Circlepath.image.withRenderingMode(.alwaysTemplate), for: .normal) loadMoreButton.setImage(Asset.Arrows.arrowTriangle2Circlepath.image.withRenderingMode(.alwaysTemplate), for: .normal)
loadMoreButton.setInsets(forContentPadding: .zero, imageTitlePadding: 4) loadMoreButton.setInsets(forContentPadding: .zero, imageTitlePadding: 4)
@ -46,3 +36,20 @@ extension TimelineMiddleLoaderTableViewCell {
delegate?.timelineMiddleLoaderTableViewCell(self, loadMoreButtonDidPressed: sender) delegate?.timelineMiddleLoaderTableViewCell(self, loadMoreButtonDidPressed: sender)
} }
} }
#if canImport(SwiftUI) && DEBUG
import SwiftUI
struct TimelineMiddleLoaderTableViewCell_Previews: PreviewProvider {
static var previews: some View {
UIViewPreview(width: 375) {
TimelineMiddleLoaderTableViewCell()
}
.previewLayout(.fixed(width: 375, height: 100))
}
}
#endif

View File

@ -139,7 +139,7 @@ extension APIService {
return APIService.Persist.persistTimeline( return APIService.Persist.persistTimeline(
managedObjectContext: self.backgroundManagedObjectContext, managedObjectContext: self.backgroundManagedObjectContext,
domain: mastodonAuthenticationBox.domain, domain: mastodonAuthenticationBox.domain,
query: query as! TimelineQueryType, query: query,
response: response, response: response,
persistType: .likeList, persistType: .likeList,
requestMastodonUserID: requestMastodonUserID, requestMastodonUserID: requestMastodonUserID,

View File

@ -13,14 +13,14 @@ extension Mastodon.Entity {
/// - Since: 2.8.0 /// - Since: 2.8.0
/// - Version: 3.3.0 /// - Version: 3.3.0
/// # Last Update /// # Last Update
/// 2021/2/4 /// 2021/2/24
/// # Reference /// # Reference
/// [Document](https://docs.joinmastodon.org/entities/poll/) /// [Document](https://docs.joinmastodon.org/entities/poll/)
public struct Poll: Codable { public struct Poll: Codable {
public typealias ID = String public typealias ID = String
public let id: ID public let id: ID
public let expiresAt: Date public let expiresAt: Date? // if nil the poll does not end
public let expired: Bool public let expired: Bool
public let multiple: Bool public let multiple: Bool
public let votesCount: Int public let votesCount: Int