Merge tag '0.7.7' into develop

no message
This commit is contained in:
CMK 2021-06-30 18:57:54 +08:00
commit ebf779a5e7
22 changed files with 388 additions and 274 deletions

View File

@ -3843,7 +3843,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22;
CURRENT_PROJECT_VERSION = 24;
DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets";
DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = Mastodon/Info.plist;
@ -3851,7 +3851,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.7.6;
MARKETING_VERSION = 0.7.7;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -3870,7 +3870,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22;
CURRENT_PROJECT_VERSION = 24;
DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets";
DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = Mastodon/Info.plist;
@ -3878,7 +3878,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.7.6;
MARKETING_VERSION = 0.7.7;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -4198,7 +4198,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22;
CURRENT_PROJECT_VERSION = 24;
DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets";
DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = Mastodon/Info.plist;
@ -4206,7 +4206,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.7.6;
MARKETING_VERSION = 0.7.7;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -4312,7 +4312,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22;
CURRENT_PROJECT_VERSION = 24;
DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = NotificationService/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
@ -4320,7 +4320,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 0.7.6;
MARKETING_VERSION = 0.7.7;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@ -4431,7 +4431,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22;
CURRENT_PROJECT_VERSION = 24;
DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets";
DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = Mastodon/Info.plist;
@ -4439,7 +4439,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.7.6;
MARKETING_VERSION = 0.7.7;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -4545,7 +4545,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22;
CURRENT_PROJECT_VERSION = 24;
DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = NotificationService/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
@ -4553,7 +4553,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 0.7.6;
MARKETING_VERSION = 0.7.7;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@ -4599,7 +4599,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22;
CURRENT_PROJECT_VERSION = 24;
DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = NotificationService/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
@ -4607,7 +4607,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 0.7.6;
MARKETING_VERSION = 0.7.7;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@ -4622,7 +4622,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22;
CURRENT_PROJECT_VERSION = 24;
DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = NotificationService/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
@ -4630,7 +4630,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 0.7.6;
MARKETING_VERSION = 0.7.7;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@ -4778,7 +4778,7 @@
repositoryURL = "https://github.com/TwidereProject/MetaTextView.git";
requirement = {
kind = exactVersion;
version = 1.2.2;
version = 1.2.3;
};
};
DB0E2D2C26833FF600865C3C /* XCRemoteSwiftPackageReference "Nuke-FLAnimatedImage-Plugin" */ = {

View File

@ -114,8 +114,8 @@
"repositoryURL": "https://github.com/TwidereProject/MetaTextView.git",
"state": {
"branch": null,
"revision": "d48cf6a2479ce6fc4f836b6c4d7ba855cdbc71cc",
"version": "1.2.2"
"revision": "5b86b386464be8a6da5383aa714c458c07da6c01",
"version": "1.2.3"
}
},
{

View File

@ -58,14 +58,8 @@ extension ComposeStatusSection {
}
protocol CustomEmojiReplaceableTextInput: AnyObject {
protocol CustomEmojiReplaceableTextInput: UITextInput & UIResponder {
var inputView: UIView? { get set }
func reloadInputViews()
// UIKeyInput
func insertText(_ text: String)
// UIResponder
var isFirstResponder: Bool { get }
}
class CustomEmojiReplaceableTextInputReference {

View File

@ -953,10 +953,15 @@ extension StatusSection {
return L10n.Common.Controls.Timeline.Accessibility.countReblogs(status.reblogsCount.intValue)
}()
// disable reblog when non-public (except self)
// disable reblog if needs (except self)
cell.statusView.actionToolbarContainer.reblogButton.isEnabled = true
if let visibility = status.visibilityEnum, visibility != .public, status.author.id != requestUserID {
cell.statusView.actionToolbarContainer.reblogButton.isEnabled = false
if let visibility = status.visibilityEnum, status.author.id != requestUserID {
switch visibility {
case .public, .unlisted:
break
default:
cell.statusView.actionToolbarContainer.reblogButton.isEnabled = false
}
}
// set like

View File

@ -29,6 +29,23 @@ extension UserProviderFacade {
mastodonUser: provider.mastodonUser().eraseToAnyPublisher()
)
}
static func toggleUserFollowRelationship(
provider: UserProvider,
mastodonUser: MastodonUser
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
// prepare authentication
guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else {
assertionFailure()
return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher()
}
return _toggleUserFollowRelationship(
context: provider.context,
activeMastodonAuthenticationBox: activeMastodonAuthenticationBox,
mastodonUser: Just(mastodonUser).eraseToAnyPublisher()
)
}
private static func _toggleUserFollowRelationship(
context: AppContext,
@ -52,6 +69,22 @@ extension UserProviderFacade {
}
extension UserProviderFacade {
static func toggleUserBlockRelationship(
provider: UserProvider,
mastodonUser: MastodonUser
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
// prepare authentication
guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else {
assertionFailure()
return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher()
}
return _toggleUserBlockRelationship(
context: provider.context,
activeMastodonAuthenticationBox: activeMastodonAuthenticationBox,
mastodonUser: Just(mastodonUser).eraseToAnyPublisher()
)
}
static func toggleUserBlockRelationship(
provider: UserProvider,
cell: UITableViewCell?
@ -98,6 +131,23 @@ extension UserProviderFacade {
}
extension UserProviderFacade {
static func toggleUserMuteRelationship(
provider: UserProvider,
mastodonUser: MastodonUser
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
// prepare authentication
guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else {
assertionFailure()
return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher()
}
return _toggleUserMuteRelationship(
context: provider.context,
activeMastodonAuthenticationBox: activeMastodonAuthenticationBox,
mastodonUser: Just(mastodonUser).eraseToAnyPublisher()
)
}
static func toggleUserMuteRelationship(
provider: UserProvider,
cell: UITableViewCell?

View File

@ -236,6 +236,10 @@ extension ComposeViewModel: UITableViewDataSource {
}
// configure author
ComposeStatusSection.configureStatusContent(cell: cell, attribute: composeStatusAttribute)
// configure content. bind text in UITextViewDelegate
if let composeContent = composeStatusAttribute.composeContent.value {
cell.metaText.textView.text = composeContent
}
// configure content warning
cell.statusContentWarningEditorView.textView.text = composeStatusAttribute.contentWarningContent.value
// bind content warning
@ -254,7 +258,6 @@ extension ComposeViewModel: UITableViewDataSource {
}
}
.store(in: &cell.disposeBag)
cell.contentWarningContent
.removeDuplicates()
.receive(on: DispatchQueue.main)

View File

@ -292,33 +292,32 @@ final class ComposeViewModel: NSObject {
.store(in: &disposeBag)
// setup attribute updater
Publishers.CombineLatest(
attachmentServices,
context.timestampUpdatePublisher
)
.sink { attachmentServices, _ in
// drive service upload state
// make image upload in the queue
for attachmentService in attachmentServices {
// skip when prefix N task when task finish OR fail OR uploading
guard let currentState = attachmentService.uploadStateMachine.currentState else { break }
if currentState is MastodonAttachmentService.UploadState.Fail {
continue
}
if currentState is MastodonAttachmentService.UploadState.Finish {
continue
}
if currentState is MastodonAttachmentService.UploadState.Uploading {
break
}
// trigger uploading one by one
if currentState is MastodonAttachmentService.UploadState.Initial {
attachmentService.uploadStateMachine.enter(MastodonAttachmentService.UploadState.Uploading.self)
break
attachmentServices
.receive(on: DispatchQueue.main)
.debounce(for: 0.3, scheduler: DispatchQueue.main)
.sink { attachmentServices in
// drive service upload state
// make image upload in the queue
for attachmentService in attachmentServices {
// skip when prefix N task when task finish OR fail OR uploading
guard let currentState = attachmentService.uploadStateMachine.currentState else { break }
if currentState is MastodonAttachmentService.UploadState.Fail {
continue
}
if currentState is MastodonAttachmentService.UploadState.Finish {
continue
}
if currentState is MastodonAttachmentService.UploadState.Uploading {
break
}
// trigger uploading one by one
if currentState is MastodonAttachmentService.UploadState.Initial {
attachmentService.uploadStateMachine.enter(MastodonAttachmentService.UploadState.Uploading.self)
break
}
}
}
}
.store(in: &disposeBag)
.store(in: &disposeBag)
// bind delegate
attachmentServices

View File

@ -93,9 +93,8 @@ extension ComposeStatusAttachmentTableViewCell {
cell.attachmentContainerView.previewImageView.image = placeholder
return
}
// cannot get correct size. set corner radius on layer
cell.attachmentContainerView.previewImageView.image = image
.af.imageAspectScaled(toFill: size)
.af.imageRounded(withCornerRadius: AttachmentContainerView.containerViewCornerRadius)
}
.store(in: &cell.disposeBag)
Publishers.CombineLatest(

View File

@ -38,6 +38,21 @@ final class ComposeStatusContentTableViewCell: UITableViewCell {
attributes: attributes
)
}()
let paragraphStyle: NSMutableParagraphStyle = {
let style = NSMutableParagraphStyle()
style.lineSpacing = 5
return style
}()
metaText.textAttributes = [
.font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)),
.foregroundColor: Asset.Colors.Label.primary.color,
.paragraphStyle: paragraphStyle,
]
metaText.linkAttributes = [
.font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold)),
.foregroundColor: Asset.Colors.brandBlue.color,
.paragraphStyle: paragraphStyle,
]
return metaText
}()
@ -68,6 +83,7 @@ extension ComposeStatusContentTableViewCell {
private func _init() {
selectionStyle = .none
layer.zPosition = 999
backgroundColor = .clear
preservesSuperviewLayoutMargins = true
let containerStackView = UIStackView()

View File

@ -19,6 +19,8 @@ final class AttachmentContainerView: UIView {
let previewImageView: UIImageView = {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFill
imageView.layer.cornerRadius = AttachmentContainerView.containerViewCornerRadius
imageView.layer.cornerCurve = .continuous
imageView.layer.masksToBounds = true
return imageView
}()

View File

@ -46,8 +46,23 @@ extension CustomEmojiPickerInputViewModel {
removeEmptyReferences()
for reference in customEmojiReplaceableTextInputReferences {
guard reference.value?.isFirstResponder == true else { continue }
reference.value?.insertText(text)
guard let textInput = reference.value else { continue }
guard textInput.isFirstResponder == true else { continue }
let selectedTextRange = textInput.selectedTextRange
textInput.insertText(text)
// due to insert text render as attachment
// the cursor reset logic not works
// hack with hard code +2 offset
assert(text.hasSuffix(": "))
if text.hasPrefix(":") && text.hasSuffix(": "),
let selectedTextRange = selectedTextRange,
let newPosition = textInput.position(from: selectedTextRange.start, offset: 2) {
let newSelectedTextRange = textInput.textRange(from: newPosition, to: newPosition)
textInput.selectedTextRange = newSelectedTextRange
}
return reference
}

View File

@ -93,6 +93,7 @@ extension NotificationViewController {
.receive(on: RunLoop.main)
.sink { [weak self] in
guard let self = self else { return }
guard self.viewModel.needsScrollToTopAfterDataSourceUpdate else { return }
self.viewModel.needsScrollToTopAfterDataSourceUpdate = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.33) {
self.scrollToTop(animated: true)
@ -106,6 +107,9 @@ extension NotificationViewController {
.sink { [weak self] segment in
guard let self = self else { return }
self.segmentControl.selectedSegmentIndex = segment.rawValue
// trigger scroll-to-top after data reload
self.viewModel.needsScrollToTopAfterDataSourceUpdate = true
guard let domain = self.viewModel.activeMastodonAuthenticationBox.value?.domain, let userID = self.viewModel.activeMastodonAuthenticationBox.value?.userID else {
return

View File

@ -20,14 +20,13 @@ extension SearchViewController: UserProvider {
func mastodonUser() -> Future<MastodonUser?, Never> {
Future { promise in
promise(.success(self.viewModel.mastodonUser.value))
promise(.success(nil))
}
}
}
extension SearchViewController: SearchRecommendAccountsCollectionViewCellDelegate {
func followButtonDidPressed(clickedUser: MastodonUser) {
viewModel.mastodonUser.value = clickedUser
guard let currentMastodonUser = viewModel.currentMastodonUser.value else {
return
}
@ -36,17 +35,17 @@ extension SearchViewController: SearchRecommendAccountsCollectionViewCellDelegat
case .none:
break
case .follow, .following:
UserProviderFacade.toggleUserFollowRelationship(provider: self)
UserProviderFacade.toggleUserFollowRelationship(provider: self, mastodonUser: clickedUser)
.sink { _ in
// error handling
} receiveValue: { _ in
// success
}
.store(in: &disposeBag)
case .pending:
break
case .muting:
guard let mastodonUser = viewModel.mastodonUser.value else { return }
let name = mastodonUser.displayNameWithFallback
let name = clickedUser.displayNameWithFallback
let alertController = UIAlertController(
title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.title,
message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.message(name),
@ -54,7 +53,7 @@ extension SearchViewController: SearchRecommendAccountsCollectionViewCellDelegat
)
let unmuteAction = UIAlertAction(title: L10n.Common.Controls.Friendship.unmute, style: .default) { [weak self] _ in
guard let self = self else { return }
UserProviderFacade.toggleUserMuteRelationship(provider: self, cell: nil)
UserProviderFacade.toggleUserMuteRelationship(provider: self, mastodonUser: clickedUser)
.sink { _ in
// do nothing
} receiveValue: { _ in
@ -67,8 +66,7 @@ extension SearchViewController: SearchRecommendAccountsCollectionViewCellDelegat
alertController.addAction(cancelAction)
present(alertController, animated: true, completion: nil)
case .blocking:
guard let mastodonUser = viewModel.mastodonUser.value else { return }
let name = mastodonUser.displayNameWithFallback
let name = clickedUser.displayNameWithFallback
let alertController = UIAlertController(
title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.title,
message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.message(name),
@ -76,7 +74,7 @@ extension SearchViewController: SearchRecommendAccountsCollectionViewCellDelegat
)
let unblockAction = UIAlertAction(title: L10n.Common.Controls.Friendship.unblock, style: .default) { [weak self] _ in
guard let self = self else { return }
UserProviderFacade.toggleUserBlockRelationship(provider: self, cell: nil)
UserProviderFacade.toggleUserBlockRelationship(provider: self, mastodonUser: clickedUser)
.sink { _ in
// do nothing
} receiveValue: { _ in

View File

@ -21,7 +21,6 @@ final class SearchViewModel: NSObject {
let context: AppContext
weak var coordinator: SceneCoordinator!
let mastodonUser = CurrentValueSubject<MastodonUser?, Never>(nil)
let currentMastodonUser = CurrentValueSubject<MastodonUser?, Never>(nil)
let viewDidAppeared = PassthroughSubject<Void, Never>()
@ -33,7 +32,7 @@ final class SearchViewModel: NSObject {
let searchResult = CurrentValueSubject<Mastodon.Entity.SearchResult?, Never>(nil)
var recommendHashTags = [Mastodon.Entity.Tag]()
// var recommendHashTags = [Mastodon.Entity.Tag]()
var recommendAccounts = [NSManagedObjectID]()
var recommendAccountsFallback = PassthroughSubject<Void, Never>()
@ -61,11 +60,7 @@ final class SearchViewModel: NSObject {
self.coordinator = coordinator
self.context = context
super.init()
guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else {
return
}
// bind active authentication
context.authenticationService.activeMastodonAuthentication
.sink { [weak self] activeMastodonAuthentication in
@ -86,26 +81,43 @@ final class SearchViewModel: NSObject {
.filter { text, _ in
!text.isEmpty
}
.flatMap { (text, scope) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> in
let query = Mastodon.API.V2.Search.Query(q: text,
type: scope,
accountID: nil,
maxID: nil,
minID: nil,
excludeUnreviewed: nil,
resolve: nil,
limit: nil,
offset: nil,
following: nil)
return context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
.compactMap { (text, scope) -> AnyPublisher<Result<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error>, Never>? in
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return nil }
let query = Mastodon.API.V2.Search.Query(
q: text,
type: scope,
accountID: nil,
maxID: nil,
minID: nil,
excludeUnreviewed: nil,
resolve: nil,
limit: nil,
offset: nil,
following: nil
)
return context.apiService.search(
domain: activeMastodonAuthenticationBox.domain,
query: query,
mastodonAuthenticationBox: activeMastodonAuthenticationBox
)
// .retry(3) // iOS 14.0 SDK may not works here. needs testing before add this
.map { response in Result<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> { response } }
.catch { error in Just(Result<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> { throw error }) }
.eraseToAnyPublisher()
}
.sink { _ in
} receiveValue: { [weak self] result in
self?.searchResult.value = result.value
.switchToLatest()
.sink { [weak self] result in
guard let self = self else { return }
switch result {
case .success(let response):
guard self.isSearching.value else { return }
self.searchResult.value = response.value
case .failure(let error):
break
}
}
.store(in: &disposeBag)
isSearching
.sink { [weak self] isSearching in
if !isSearching {
@ -147,48 +159,71 @@ final class SearchViewModel: NSObject {
}
.store(in: &disposeBag)
viewDidAppeared
.compactMap { _ in self.requestRecommendHashTags() }
.receive(on: RunLoop.main)
.sink { [weak self] _ in
guard let self = self else { return }
if !self.recommendHashTags.isEmpty {
guard let dataSource = self.hashtagDiffableDataSource else { return }
var snapshot = NSDiffableDataSourceSnapshot<RecommendHashTagSection, Mastodon.Entity.Tag>()
snapshot.appendSections([.main])
snapshot.appendItems(self.recommendHashTags, toSection: .main)
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
}
} receiveValue: { _ in
Publishers.CombineLatest(
context.authenticationService.activeMastodonAuthenticationBox,
viewDidAppeared
)
.compactMap { activeMastodonAuthenticationBox, _ -> AuthenticationService.MastodonAuthenticationBox? in
return activeMastodonAuthenticationBox
}
.throttle(for: 1, scheduler: DispatchQueue.main, latest: false)
.flatMap { box in
context.apiService.recommendTrends(domain: box.domain, query: nil)
.map { response in Result<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, Error> { response } }
.catch { error in Just(Result<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, Error> { throw error }) }
.eraseToAnyPublisher()
}
.receive(on: RunLoop.main)
.sink { [weak self] result in
guard let self = self else { return }
switch result {
case .success(let response):
guard let dataSource = self.hashtagDiffableDataSource else { return }
var snapshot = NSDiffableDataSourceSnapshot<RecommendHashTagSection, Mastodon.Entity.Tag>()
snapshot.appendSections([.main])
snapshot.appendItems(response.value, toSection: .main)
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
case .failure(let error):
break
}
.store(in: &disposeBag)
viewDidAppeared
.compactMap { _ in self.requestRecommendAccountsV2() }
.receive(on: RunLoop.main)
.sink { [weak self] _ in
guard let self = self else { return }
if !self.recommendAccounts.isEmpty {
self.applyDataSource()
}
} receiveValue: { _ in
}
.store(in: &disposeBag)
recommendAccountsFallback
.receive(on: RunLoop.main)
.sink { [weak self] _ in
guard let self = self else { return }
self.requestRecommendAccounts()
.sink { [weak self] _ in
guard let self = self else { return }
if !self.recommendAccounts.isEmpty {
self.applyDataSource()
}
} receiveValue: { _ in
}
.store(in: &disposeBag)
Publishers.CombineLatest(
context.authenticationService.activeMastodonAuthenticationBox,
viewDidAppeared
)
.compactMap { activeMastodonAuthenticationBox, _ -> AuthenticationService.MastodonAuthenticationBox? in
return activeMastodonAuthenticationBox
}
.throttle(for: 1, scheduler: DispatchQueue.main, latest: false)
.flatMap { box -> AnyPublisher<Result<[Mastodon.Entity.Account.ID], Error>, Never> in
context.apiService.suggestionAccountV2(domain: box.domain, query: nil, mastodonAuthenticationBox: box)
.map { response in Result<[Mastodon.Entity.Account.ID], Error> { response.value.map { $0.account.id } } }
.catch { error -> AnyPublisher<Result<[Mastodon.Entity.Account.ID], Error>, Never> in
if let apiError = error as? Mastodon.API.Error, apiError.httpResponseStatus == .notFound {
return context.apiService.suggestionAccount(domain: box.domain, query: nil, mastodonAuthenticationBox: box)
.map { response in Result<[Mastodon.Entity.Account.ID], Error> { response.value.map { $0.id } } }
.catch { error in Just(Result<[Mastodon.Entity.Account.ID], Error> { throw error }) }
.eraseToAnyPublisher()
} else {
return Just(Result<[Mastodon.Entity.Account.ID], Error> { throw error })
.eraseToAnyPublisher()
}
.store(in: &self.disposeBag)
}
.eraseToAnyPublisher()
}
.receive(on: RunLoop.main)
.sink { [weak self] result in
guard let self = self else { return }
switch result {
case .success(let userIDs):
self.receiveAccounts(ids: userIDs)
case .failure(let error):
break
}
.store(in: &disposeBag)
}
.store(in: &disposeBag)
searchResult
.receive(on: DispatchQueue.main)
@ -217,96 +252,7 @@ final class SearchViewModel: NSObject {
.store(in: &disposeBag)
}
func requestRecommendHashTags() -> Future<Void, Error> {
Future { promise in
guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else {
promise(.failure(APIService.APIError.implicit(APIService.APIError.ErrorReason.authenticationMissing)))
return
}
self.context.apiService.recommendTrends(domain: activeMastodonAuthenticationBox.domain, query: nil)
.sink { completion in
switch completion {
case .failure(let error):
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendHashTags request fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
promise(.failure(error))
case .finished:
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendHashTags request success", (#file as NSString).lastPathComponent, #line, #function)
promise(.success(()))
}
} receiveValue: { [weak self] tags in
guard let self = self else { return }
self.recommendHashTags = tags.value
}
.store(in: &self.disposeBag)
}
}
func requestRecommendAccountsV2() -> Future<Void, Error> {
Future { promise in
guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else {
promise(.failure(APIService.APIError.implicit(APIService.APIError.ErrorReason.authenticationMissing)))
return
}
self.context.apiService.suggestionAccountV2(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
.sink { [weak self] completion in
switch completion {
case .failure(let error):
if let apiError = error as? Mastodon.API.Error {
if apiError.httpResponseStatus == .notFound {
self?.recommendAccountsFallback.send()
}
}
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount request fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
promise(.failure(error))
case .finished:
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount request success", (#file as NSString).lastPathComponent, #line, #function)
promise(.success(()))
}
} receiveValue: { [weak self] accounts in
guard let self = self else { return }
let ids = accounts.value.compactMap({$0.account.id})
self.receiveAccounts(ids: ids)
}
.store(in: &self.disposeBag)
}
}
func requestRecommendAccounts() -> Future<Void, Error> {
Future { promise in
guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else {
promise(.failure(APIService.APIError.implicit(APIService.APIError.ErrorReason.authenticationMissing)))
return
}
self.context.apiService.suggestionAccount(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
.sink { completion in
switch completion {
case .failure(let error):
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount request fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
promise(.failure(error))
case .finished:
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount request success", (#file as NSString).lastPathComponent, #line, #function)
promise(.success(()))
}
} receiveValue: { [weak self] accounts in
guard let self = self else { return }
let ids = accounts.value.compactMap({$0.id})
self.receiveAccounts(ids: ids)
}
.store(in: &self.disposeBag)
}
}
func applyDataSource() {
DispatchQueue.main.async {
guard let dataSource = self.accountDiffableDataSource else { return }
var snapshot = NSDiffableDataSourceSnapshot<RecommendAccountSection, NSManagedObjectID>()
snapshot.appendSections([.main])
snapshot.appendItems(self.recommendAccounts, toSection: .main)
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
}
}
func receiveAccounts(ids: [String]) {
func receiveAccounts(ids: [Mastodon.Entity.Account.ID]) {
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
return
}
@ -323,12 +269,23 @@ final class SearchViewModel: NSObject {
return nil
}
}()
if let users = mastodonUsers {
let sortedUsers = users.sorted { (user1, user2) -> Bool in
(ids.firstIndex(of: user1.id) ?? 0) < (ids.firstIndex(of: user2.id) ?? 0)
guard let users = mastodonUsers else { return }
let objectIDs: [NSManagedObjectID] = users
.compactMap { object in
ids.firstIndex(of: object.id).map { index in (index, object) }
}
recommendAccounts = sortedUsers.map(\.objectID)
}
.sorted { $0.0 < $1.0 }
.map { $0.1.objectID }
// append at front
let newObjectIDs = objectIDs.filter { !self.recommendAccounts.contains($0) }
self.recommendAccounts = newObjectIDs + self.recommendAccounts
guard let dataSource = self.accountDiffableDataSource else { return }
var snapshot = NSDiffableDataSourceSnapshot<RecommendAccountSection, NSManagedObjectID>()
snapshot.appendSections([.main])
snapshot.appendItems(self.recommendAccounts, toSection: .main)
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
}
func accountCollectionViewItemDidSelected(mastodonUser: MastodonUser, from: UIViewController) {

View File

@ -217,6 +217,21 @@ final class StatusView: UIView {
metaText.textView.textContainer.lineFragmentPadding = 0
metaText.textView.textContainerInset = .zero
metaText.textView.layer.masksToBounds = false
let paragraphStyle: NSMutableParagraphStyle = {
let style = NSMutableParagraphStyle()
style.lineSpacing = 5
return style
}()
metaText.textAttributes = [
.font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)),
.foregroundColor: Asset.Colors.Label.primary.color,
.paragraphStyle: paragraphStyle,
]
metaText.linkAttributes = [
.font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold)),
.foregroundColor: Asset.Colors.brandBlue.color,
.paragraphStyle: paragraphStyle,
]
return metaText
}()
@ -338,7 +353,9 @@ extension StatusView {
nameTrialingDotLabel.setContentCompressionResistancePriority(.required - 2, for: .horizontal)
dateLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal)
dateLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal)
visibilityImageView.setContentHuggingPriority(.defaultHigh + 3, for: .horizontal)
visibilityImageView.setContentHuggingPriority(.required - 1, for: .horizontal)
visibilityImageView.setContentHuggingPriority(.required - 1, for: .vertical)
visibilityImageView.setContentCompressionResistancePriority(.required - 1, for: .horizontal)
// subtitle container: [username]
let subtitleContainerStackView = UIStackView()

View File

@ -37,7 +37,8 @@ final class VideoPlayerViewModel {
private var timeControlStatusObservation: NSKeyValueObservation?
let timeControlStatus = CurrentValueSubject<AVPlayer.TimeControlStatus, Never>(.paused)
let playbackState = CurrentValueSubject<PlaybackState, Never>(PlaybackState.unknown)
init(previewImageURL: URL?, videoURL: URL, videoSize: CGSize, videoKind: VideoPlayerViewModel.Kind) {
self.previewImageURL = previewImageURL
self.videoURL = videoURL
@ -58,18 +59,42 @@ final class VideoPlayerViewModel {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: player state: %s", (#file as NSString).lastPathComponent, #line, #function, player.timeControlStatus.debugDescription)
self.timeControlStatus.value = player.timeControlStatus
}
// update audio session category for user interactive event stream
player.publisher(for: \.status, options: [.initial, .new])
.sink(receiveValue: { [weak self] status in
guard let self = self else { return }
switch status {
case .failed:
self.playbackState.value = .failed
case .readyToPlay:
self.playbackState.value = .readyToPlay
case .unknown:
self.playbackState.value = .unknown
@unknown default:
assertionFailure()
}
})
.store(in: &disposeBag)
timeControlStatus
.sink { [weak self] timeControlStatus in
guard let _ = self else { return }
guard timeControlStatus == .playing else { return }
NotificationCenter.default.post(name: VideoPlayerViewModel.appWillPlayVideoNotification, object: nil)
switch videoKind {
case .gif:
break
case .video:
try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default)
guard let self = self else { return }
// emit playing event
if timeControlStatus == .playing {
NotificationCenter.default.post(name: VideoPlayerViewModel.appWillPlayVideoNotification, object: nil)
}
switch timeControlStatus {
case .paused:
self.playbackState.value = .paused
case .waitingToPlayAtSpecifiedRate:
self.playbackState.value = .buffering
case .playing:
self.playbackState.value = .playing
@unknown default:
assertionFailure()
self.playbackState.value = .unknown
}
}
.store(in: &disposeBag)
@ -81,6 +106,27 @@ final class VideoPlayerViewModel {
isPlay ? self.play() : self.pause()
}
.store(in: &disposeBag)
let sessionName = videoKind == .gif ? "GIF" : "Video"
playbackState
.receive(on: RunLoop.main)
.sink { [weak self] status in
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: %s status: %s", ((#file as NSString).lastPathComponent), #line, #function, sessionName, status.description)
guard let self = self else { return }
// only update audio session for video
guard self.videoKind == .video else { return }
switch status {
case .unknown, .buffering, .readyToPlay:
break
case .playing:
try? AVAudioSession.sharedInstance().setCategory(.soloAmbient)
try? AVAudioSession.sharedInstance().setActive(true)
case .paused, .stopped, .failed:
try? AVAudioSession.sharedInstance().setCategory(.ambient)
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
}
.store(in: &disposeBag)
}
deinit {
@ -107,7 +153,8 @@ extension VideoPlayerViewModel {
case .gif:
break
case .video:
try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default)
break
// try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default)
}
player.play()

View File

@ -53,7 +53,7 @@ class ThreadViewModel {
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), $0.emojiDict) }
optionalStatus.flatMap { (L10n.Scene.Thread.title($0.author.displayNameWithFallback), $0.author.emojiDict) }
)
// bind fetcher domain
@ -239,7 +239,7 @@ extension ThreadViewModel {
nextID = object.inReplyToID
}
}
return nodes.reversed()
return nodes
}
}

View File

@ -23,7 +23,6 @@ final class AudioPlaybackService: NSObject {
var statusObserver: Any?
var attachment: Attachment?
let session = AVAudioSession.sharedInstance()
let playbackState = CurrentValueSubject<PlaybackState, Never>(PlaybackState.unknown)
let currentTimeSubject = CurrentValueSubject<TimeInterval, Never>(0)
@ -31,6 +30,23 @@ final class AudioPlaybackService: NSObject {
override init() {
super.init()
addObserver()
playbackState
.receive(on: RunLoop.main)
.sink { status in
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: audio status: %s", ((#file as NSString).lastPathComponent), #line, #function, status.description)
switch status {
case .unknown, .buffering, .readyToPlay:
break
case .playing:
try? AVAudioSession.sharedInstance().setCategory(.soloAmbient)
try? AVAudioSession.sharedInstance().setActive(true)
case .paused, .stopped, .failed:
try? AVAudioSession.sharedInstance().setCategory(.ambient)
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
}
.store(in: &disposeBag)
}
}
@ -39,12 +55,6 @@ extension AudioPlaybackService {
guard let url = URL(string: audioAttachment.url) else {
return
}
do {
try session.setCategory(.playback)
} catch {
print(error)
return
}
notifyWillPlayAudioNotification()
if audioAttachment == attachment {
@ -64,27 +74,6 @@ extension AudioPlaybackService {
}
func addObserver() {
UIDevice.current.isProximityMonitoringEnabled = true
NotificationCenter.default.publisher(for: UIDevice.proximityStateDidChangeNotification, object: nil)
.sink { [weak self] _ in
guard let self = self else { return }
if UIDevice.current.proximityState == true {
do {
try self.session.setCategory(.playAndRecord)
} catch {
print(error)
return
}
} else {
do {
try self.session.setCategory(.playback)
} catch {
print(error)
return
}
}
}
.store(in: &disposeBag)
NotificationCenter.default.publisher(for: VideoPlayerViewModel.appWillPlayVideoNotification)
.sink { [weak self] _ in
guard let self = self else { return }
@ -96,7 +85,7 @@ extension AudioPlaybackService {
guard let self = self else { return }
self.currentTimeSubject.value = time.seconds
})
player.publisher(for: \.status, options: .new)
player.publisher(for: \.status, options: [.initial, .new])
.sink(receiveValue: { [weak self] status in
guard let self = self else { return }
switch status {

View File

@ -23,3 +23,21 @@ public enum PlaybackState : Int {
case failed = 6
}
// MARK: - CustomStringConvertible
extension PlaybackState: CustomStringConvertible {
public var description: String {
switch self {
case .unknown: return "unknown"
case .buffering: return "buffering"
case .readyToPlay: return "readyToPlay"
case .playing: return "playing"
case .paused: return "paused"
case .stopped: return "stopped"
case .failed: return "failed"
default:
assertionFailure()
return "<nil>"
}
}
}

View File

@ -40,7 +40,7 @@ extension VideoPlaybackService {
} else {
if latestPlayingVideoPlayerViewModel === playerViewModel {
latestPlayingVideoPlayerViewModel = nil
try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default)
// try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default)
}
}
}
@ -111,7 +111,7 @@ extension VideoPlaybackService {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function)
// note: do not retain view controller
// pause all player when view disppear exclude full screen player and other transitioning scene
// pause all player when view disappear exclude full screen player and other transitioning scene
for viewModel in viewPlayerViewModelDict.values {
guard !viewModel.isTransitioning else {
viewModel.isTransitioning = false

View File

@ -9,6 +9,7 @@ import os.log
import UIKit
import UserNotifications
import AppShared
import AVFoundation
#if ASDK
import AsyncDisplayKit
@ -55,7 +56,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
// Use this method to release any resources that were specific to the discarded scenes, as they will not return.
}
}
extension AppDelegate {

View File

@ -83,6 +83,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// Called as the scene transitions from the foreground to the background.
// Use this method to save data, release shared resources, and store enough scene-specific state information
// to restore the scene back to its current state.
AppContext.shared.audioPlaybackService.pauseIfNeed()
}