From 484c0c5e9f62c6be0e538e526b0de1a38a031808 Mon Sep 17 00:00:00 2001 From: rinsuki <428rinsuki+git@gmail.com> Date: Mon, 12 Feb 2024 17:46:44 +0900 Subject: [PATCH] feat: add ornament for NewPostVC --- .../Mastodon/API/MastodonPostVisibility.swift | 6 +- .../NewPostMediaListViewController.swift | 136 +++++----- .../iOS/App/Screens/NewPost/NewPostView.swift | 27 +- .../NewPost/NewPostViewController.swift | 236 ++++++++++-------- .../Screens/NewPost/NewPostViewModel.swift | 108 ++++++++ iMast.xcodeproj/project.pbxproj | 4 + 6 files changed, 342 insertions(+), 175 deletions(-) create mode 100644 Sources/iOS/App/Screens/NewPost/NewPostViewModel.swift diff --git a/Sources/Core/Mastodon/API/MastodonPostVisibility.swift b/Sources/Core/Mastodon/API/MastodonPostVisibility.swift index 79a826742..ed5ac1697 100644 --- a/Sources/Core/Mastodon/API/MastodonPostVisibility.swift +++ b/Sources/Core/Mastodon/API/MastodonPostVisibility.swift @@ -26,12 +26,16 @@ import Foundation import UIKit #endif -public enum MastodonPostVisibility: String, CaseIterable, Codable, Sendable { +public enum MastodonPostVisibility: String, CaseIterable, Codable, Sendable, Identifiable { case `public` case unlisted case `private` case direct + public var id: String { + rawValue + } + public var localizedName: String { switch self { case .public: diff --git a/Sources/iOS/App/Screens/NewPost/NewPostMediaListViewController.swift b/Sources/iOS/App/Screens/NewPost/NewPostMediaListViewController.swift index d9a1ec736..8e37e0368 100644 --- a/Sources/iOS/App/Screens/NewPost/NewPostMediaListViewController.swift +++ b/Sources/iOS/App/Screens/NewPost/NewPostMediaListViewController.swift @@ -30,17 +30,19 @@ import Ikemen import iMastiOSCore class NewPostMediaListViewController: UIViewController { - - let newPostVC: NewPostViewController + let viewModel: NewPostViewModel + var inline: Bool // TODO: contact じゃないのに使っていいの? アクセシビリティ周りマズそう let addButton = UIButton(type: .contactAdd) let imagesStackView = UIStackView() ※ { v in v.distribution = .fillEqually + v.alignment = .leading } - init(newPostVC: NewPostViewController) { - self.newPostVC = newPostVC + init(viewModel: NewPostViewModel, inline: Bool) { + self.viewModel = viewModel + self.inline = inline super.init(nibName: nil, bundle: nil) } @@ -52,57 +54,61 @@ class NewPostMediaListViewController: UIViewController { super.viewDidLoad() // Do any additional setup after loading the view. - let stackView = UIStackView(arrangedSubviews: [ - addButton, - imagesStackView, - ]) - stackView.spacing = 8 - view.addSubview(stackView) - stackView.snp.makeConstraints { make in - make.center.equalTo(view.safeAreaLayoutGuide) - make.size.equalTo(view.safeAreaLayoutGuide).inset(8) - } - addButton.snp.makeConstraints { make in - make.width.equalToSuperview().multipliedBy(1/5.0).inset(4) - } - - let addFromPhotoLibrary = UIAction( - title: L10n.NewPost.Media.Picker.photoLibrary, - image: UIImage(systemName: "rectangle.on.rectangle"), - handler: { [weak self] _ in - self?.addFromPhotoLibrary() + if inline { + view.addSubview(imagesStackView) + imagesStackView.spacing = 8 + imagesStackView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(8) + make.height.equalTo(72) } - ) - - #if os(visionOS) - let menu = UIMenu(children: [ - addFromPhotoLibrary, - ]) - #else - let menu = UIMenu(children: [ - addFromPhotoLibrary, - UIAction( - title: L10n.NewPost.Media.Picker.takePhoto, - image: UIImage(systemName: "camera.fill"), - handler: { [weak self] _ in - self?.addFromCamera() - } - ), - UIAction( - title: "ブラウズ", - image: UIImage(systemName: "ellipsis"), - handler: { [weak self] _ in - guard let strongSelf = self else { return } - let pickerVC = UIDocumentPickerViewController(forOpeningContentTypes: [.image], asCopy: true) - pickerVC.delegate = strongSelf - strongSelf.present(pickerVC, animated: true, completion: nil) - } - ), - ]) - #endif - addButton.preferredMenuElementOrder = .fixed - addButton.menu = menu - addButton.showsMenuAsPrimaryAction = true + } else { + let stackView = UIStackView(arrangedSubviews: [ + addButton, + imagesStackView, + ]) + stackView.spacing = 8 + view.addSubview(stackView) + stackView.snp.makeConstraints { make in + make.center.equalTo(view.safeAreaLayoutGuide) + make.size.equalTo(view.safeAreaLayoutGuide).inset(8) + } + addButton.snp.makeConstraints { make in + make.width.equalToSuperview().multipliedBy(1/5.0).inset(4) + } + let menu = UIMenu(children: [ + UIAction( + title: L10n.NewPost.Media.Picker.photoLibrary, + image: UIImage(systemName: "rectangle.on.rectangle"), + handler: { [weak self] _ in + self?.addFromPhotoLibrary() + } + ), + UIAction( + title: L10n.NewPost.Media.Picker.takePhoto, + image: UIImage(systemName: "camera.fill"), + handler: { [weak self] _ in + #if !os(visionOS) + self?.addFromCamera() + #endif + } + ), + UIAction( + title: "ブラウズ", + image: UIImage(systemName: "ellipsis"), + handler: { [weak self] _ in + #if !os(visionOS) + guard let strongSelf = self else { return } + let pickerVC = UIDocumentPickerViewController(forOpeningContentTypes: [.image], asCopy: true) + pickerVC.delegate = strongSelf + strongSelf.present(pickerVC, animated: true, completion: nil) + #endif + } + ), + ]) + addButton.preferredMenuElementOrder = .fixed + addButton.menu = menu + addButton.showsMenuAsPrimaryAction = true + } self.refresh() } @@ -117,8 +123,8 @@ class NewPostMediaListViewController: UIViewController { self.imagesStackView.removeArrangedSubview(imageView) imageView.removeFromSuperview() } - if self.newPostVC.media.count > 0 { - for (index, media) in self.newPostVC.media.enumerated() { + if viewModel.media.count > 0 { + for (index, media) in viewModel.media.enumerated() { let imageView = UIImageView(image: media.thumbnailImage) imageView.ignoreSmartInvert() imageView.contentMode = .scaleAspectFill @@ -128,6 +134,11 @@ class NewPostMediaListViewController: UIViewController { tapGesture.numberOfTapsRequired = 1 imageView.isUserInteractionEnabled = true imageView.addGestureRecognizer(tapGesture) + if inline { + imageView.snp.makeConstraints { make in + make.width.equalTo(imageView.snp.height) + } + } self.imagesStackView.addArrangedSubview(imageView) } } else { @@ -141,14 +152,19 @@ class NewPostMediaListViewController: UIViewController { } func addMedia(media: UploadableMedia) { - self.newPostVC.media.append(media) + viewModel.media.append(media) self.refresh() } func addFromPhotoLibrary() { let imgPickerC = UIImagePickerController() imgPickerC.sourceType = .photoLibrary + #if os(visionOS) + // TODO: visionOS でも動画に対応する + imgPickerC.mediaTypes = [kUTTypeImage as String] + #else imgPickerC.mediaTypes = [kUTTypeMovie as String, kUTTypeImage as String] + #endif imgPickerC.videoExportPreset = AVAssetExportPresetPassthrough showImagePickerController(imgPickerC) } @@ -168,7 +184,7 @@ class NewPostMediaListViewController: UIViewController { @objc func tapCurrentMedia(sender: UITapGestureRecognizer) { guard let index = sender.view?.tag else { return } - let media = newPostVC.media[index] + let media = viewModel.media[index] let alertVC = UIAlertController(title: nil, message: nil, preferredStyle: .alert) alertVC.addAction(UIAlertAction(title: L10n.NewPost.Media.preview, style: .default, handler: { _ in @@ -195,7 +211,7 @@ class NewPostMediaListViewController: UIViewController { } })) alertVC.addAction(UIAlertAction(title: L10n.NewPost.Media.delete, style: .destructive, handler: { _ in - self.newPostVC.media.remove(at: index) + self.viewModel.media.remove(at: index) self.refresh() })) alertVC.addAction(UIAlertAction(title: L10n.Localizable.cancel, style: .cancel, handler: nil)) @@ -204,6 +220,7 @@ class NewPostMediaListViewController: UIViewController { } } +// TODO: 新しい document picker の delegate に対応してこちらも開放する #if !os(visionOS) extension NewPostMediaListViewController: UIDocumentPickerDelegate { func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentAt url: URL) { @@ -221,6 +238,7 @@ extension NewPostMediaListViewController: UIImagePickerControllerDelegate { let data = try! Data(contentsOf: url, options: NSData.ReadingOptions.mappedIfSafe) self.addMedia(media: UploadableMedia(format: url.pathExtension.lowercased() == "png" ? .png : .jpeg, data: data, url: nil, thumbnailImage: UIImage(data: data)!)) } else if let url = info[.mediaURL] as? URL { + // TODO: visionOS でも動画投稿に対応する #if !os(visionOS) Task { let asset = AVURLAsset(url: url) diff --git a/Sources/iOS/App/Screens/NewPost/NewPostView.swift b/Sources/iOS/App/Screens/NewPost/NewPostView.swift index df8010b71..40d588db0 100644 --- a/Sources/iOS/App/Screens/NewPost/NewPostView.swift +++ b/Sources/iOS/App/Screens/NewPost/NewPostView.swift @@ -73,10 +73,10 @@ class NewPostView: UIView { $0.width = 44 } - convenience init() { - self.init(frame: .zero) - backgroundColor = .systemBackground - + let stackView: UIStackView + + init() { + #if !os(visionOS) addSubview(toolBar) toolBar.items = [ imageSelectItem, @@ -88,22 +88,31 @@ class NewPostView: UIView { make.top.equalTo(safeAreaLayoutGuide.snp.bottom) make.leading.trailing.equalToSuperview() } + #endif let separatorView = SeparatorView() - let stackView = UIStackView(arrangedSubviews: [ + stackView = .init(arrangedSubviews: [ cwInput, separatorView, textInput, ]) - stackView.alignment = .center + stackView.alignment = .leading stackView.spacing = 0 stackView.axis = .vertical + + super.init(frame: .zero) + backgroundColor = .systemBackground + addSubview(stackView) stackView.snp.makeConstraints { make in make.leading.trailing.equalToSuperview() make.top.equalTo(safeAreaLayoutGuide.snp.top) + #if !os(visionOS) make.bottom.equalTo(toolBar.snp.top) + #else + make.bottom.equalTo(safeAreaLayoutGuide.snp.bottom) + #endif } cwInput.snp.makeConstraints { make in make.leading.trailing.equalTo(safeAreaLayoutGuide) @@ -119,6 +128,12 @@ class NewPostView: UIView { currentAccountLabel.snp.makeConstraints { make in make.leading.trailing.bottom.equalTo(safeAreaLayoutGuide).inset(8) } + #if !os(visionOS) bringSubviewToFront(toolBar) + #endif + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") } } diff --git a/Sources/iOS/App/Screens/NewPost/NewPostViewController.swift b/Sources/iOS/App/Screens/NewPost/NewPostViewController.swift index b2061b075..ba3e2b069 100644 --- a/Sources/iOS/App/Screens/NewPost/NewPostViewController.swift +++ b/Sources/iOS/App/Screens/NewPost/NewPostViewController.swift @@ -22,40 +22,29 @@ // import UIKit +import SwiftUI +import Combine import Hydra import MediaPlayer import iMastiOSCore // YOU PROBABLY WANT TO ALSO MODIFY ShareNewPostViewController, which is subset of NewPostViewController. -class NewPostViewController: UIViewController, UITextViewDelegate { +class NewPostViewController: UIViewController, UITextViewDelegate, ObservableObject { var contentView: NewPostView! + var viewModel: NewPostViewModel + var cancellables = Set() + var mediaVC: NewPostMediaListViewController? - @MainActor var media: [UploadableMedia] = [] { - didSet { - // TODO: なんかこれでもアニメーションしてしまうのを防ぐ - UIView.performWithoutAnimation { - contentView.imageSelectButton.setTitle(" \(media.count)", for: .normal) - } - } - } - var isNSFW: Bool = false { - didSet { - contentView.nsfwSwitchItem.image = isNSFW ? .init(systemName: "eye.slash") : .init(systemName: "eye") - } - } - var scope = MastodonPostVisibility.public { - didSet { - contentView.scopeSelectItem.image = scope.uiImage - } - } var editPost: (post: MastodonPost, source: MastodonPostSource)? // var replyToPost: MastodonPost? var userToken: MastodonUserToken! init(userActivity: NSUserActivity) { + viewModel = .init() super.init(nibName: nil, bundle: nil) + viewModel.alertPresenter = self self.userActivity = userActivity self.userToken = userActivity.mastodonUserToken() } @@ -78,13 +67,40 @@ class NewPostViewController: UIViewController, UITextViewDelegate { contentView.imageSelectButton.addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(openImagePickerDirectly(_:)))) contentView.nsfwSwitchItem.target = self contentView.nsfwSwitchItem.action = #selector(nsfwButtonTapped(_:)) - contentView.nowPlayingItem.target = self - contentView.nowPlayingItem.action = #selector(nowPlayingTapped(_:)) + contentView.nowPlayingItem.target = viewModel + contentView.nowPlayingItem.action = #selector(viewModel.insertNowPlayingInfo) contentView.scopeSelectItem.menu = UIMenu(title: "", children: MastodonPostVisibility.allCases.map { visibility in return UIAction(title: visibility.localizedName, image: visibility.uiImage, state: .off) { [weak self] _ in - self?.scope = visibility + self?.viewModel.visibility = visibility } }) + + viewModel.$visibility + .prepend(viewModel.visibility) + .receive(on: DispatchQueue.main) + .sink { [weak contentView] scope in + contentView?.scopeSelectItem.image = scope.uiImage + } + .store(in: &cancellables) + + viewModel.$isNSFW + .prepend(viewModel.isNSFW) + .receive(on: DispatchQueue.main) + .sink { [weak contentView] isNSFW in + contentView?.nsfwSwitchItem.image = .init(systemName: isNSFW ? "eye.slash" : "eye") + } + .store(in: &cancellables) + + viewModel.$media + .prepend(viewModel.media) + .receive(on: DispatchQueue.main) + .sink { [weak contentView] media in + // TODO: なんかこれでもアニメーションしてしまうのを防ぐ + UIView.performWithoutAnimation { + contentView?.imageSelectButton.setTitle(" \(media.count)", for: .normal) + } + } + .store(in: &cancellables) contentView.currentAccountLabel.text = userToken.acct navigationItem.largeTitleDisplayMode = .never @@ -97,7 +113,7 @@ class NewPostViewController: UIViewController, UITextViewDelegate { } if let scope = MastodonPostVisibility(rawValue: userActivity?.newPostVisibility ?? "") { - self.scope = scope + viewModel.visibility = scope } else if Defaults.usingDefaultVisibility && editPost == nil { setVisibilityFromUserInfo() } @@ -112,10 +128,10 @@ class NewPostViewController: UIViewController, UITextViewDelegate { contentView.imageSelectButton.setTitle(" \(editPost.post.attachments.count)", for: .normal) contentView.imageSelectButton.isEnabled = false - scope = editPost.post.visibility + viewModel.visibility = editPost.post.visibility contentView.scopeSelectItem.isEnabled = false - isNSFW = editPost.post.sensitive + viewModel.isNSFW = editPost.post.sensitive } contentView.textInput.becomeFirstResponder() @@ -138,6 +154,80 @@ class NewPostViewController: UIViewController, UITextViewDelegate { additionalSafeAreaInsets = .init(top: 0, left: 0, bottom: 44, right: 0) configureObserver() + + #if os(visionOS) + let mediaVC = NewPostMediaListViewController(viewModel: viewModel, inline: true) + contentView.stackView.addArrangedSubview(mediaVC.view) + addChild(mediaVC) + self.mediaVC = mediaVC + viewModel.$media + .prepend(viewModel.media) + .receive(on: DispatchQueue.main) + .sink { + mediaVC.view.isHidden = $0.count == 0 + } + .store(in: &cancellables) + + struct OrnamentView: View { + @StateObject var viewModel: NewPostViewModel + + var body: some View { + HStack { + Button { + viewModel.alertPresenter?.mediaVC?.addFromPhotoLibrary() + } label: { + Image(systemName: "photo") + Text("\(viewModel.media.count)") + } + + + Toggle(isOn: $viewModel.isNSFW) { + Image(systemName: viewModel.isNSFW ? "eye.slash" : "eye" ) + } + .toggleStyle(.button) + .help("NSFW (Current: \(viewModel.isNSFW ? "ON" : "OFF"))") + + Menu { + ForEach(MastodonPostVisibility.allCases) { v in + Button { + viewModel.visibility = v + } label: { + Label { + Text(v.localizedName) + } icon: { + Image(systemName: v.sfSymbolsName) + } + } + } + } label: { + Image(systemName: viewModel.visibility.sfSymbolsName) + .aspectRatio(1, contentMode: .fit) + } + .menuStyle(.borderlessButton) + .help("Visibility (Current: \(viewModel.visibility.localizedName))") + + Divider() + + Button { + viewModel.insertNowPlayingInfo() + } label: { + Image(systemName: "music.note") + } + .help("Insert NowPlaying") + } + .padding() + .buttonStyle(.borderless) + .glassBackgroundEffect() + } + } + + let ornament = UIHostingOrnament(sceneAnchor: .bottom, contentAlignment: .center) { + OrnamentView(viewModel: self.viewModel) + } + + ornaments = [ornament] + + #endif } override func didReceiveMemoryWarning() { @@ -146,7 +236,6 @@ class NewPostViewController: UIViewController, UITextViewDelegate { } @objc func sendPost(_ sender: Any) { - print(isNSFW) let baseMessage = L10n.NewPost.Alerts.Sending.pleaseWait+"\n" let alert = UIAlertController( title: L10n.NewPost.Alerts.Sending.title, @@ -167,7 +256,7 @@ class NewPostViewController: UIViewController, UITextViewDelegate { preconditionFailure() } let text = contentView.textInput.text ?? "" - let isSensitive = isNSFW || (contentView.cwInput.text != nil && contentView.cwInput.text != "") + let isSensitive = viewModel.isNSFW || (contentView.cwInput.text != nil && contentView.cwInput.text != "") let spoilerText = contentView.cwInput.text ?? "" Task { @@ -206,15 +295,15 @@ class NewPostViewController: UIViewController, UITextViewDelegate { func submitPost(_ alert: UIAlertController) { let baseMessage = L10n.NewPost.Alerts.Sending.pleaseWait+"\n" let text = contentView.textInput.text ?? "" - let isSensitive = isNSFW || (contentView.cwInput.text != nil && contentView.cwInput.text != "") + let isSensitive = viewModel.isNSFW || (contentView.cwInput.text != nil && contentView.cwInput.text != "") let spoilerText = contentView.cwInput.text ?? "" - let scope = scope + let scope = viewModel.visibility asyncPromise { var media: [MastodonAttachment] = [] - for (index, medium) in self.media.enumerated() { + for (index, medium) in self.viewModel.media.enumerated() { await MainActor.run { - alert.message = baseMessage + L10n.NewPost.Alerts.Sending.Steps.mediaUpload(index+1, self.media.count) + alert.message = baseMessage + L10n.NewPost.Alerts.Sending.Steps.mediaUpload(index+1, self.viewModel.media.count) } let response = try await MastodonEndpoint.UploadMediaV1(file: medium.toUploadableData(), mimeType: medium.getMimeType()).request(with: self.userToken) media.append(response) @@ -277,82 +366,11 @@ class NewPostViewController: UIViewController, UITextViewDelegate { #endif @objc func nsfwButtonTapped(_ sender: Any) { - isNSFW = !isNSFW - } - @objc func nowPlayingTapped(_ sender: Any) { - switch MPMediaLibrary.authorizationStatus() { - case .denied: - self.alert( - title: L10n.Localizable.Error.title, - message: L10n.NewPost.Errors.declineAppleMusicPermission - ) - return - case .notDetermined: - MPMediaLibrary.requestAuthorization { [weak self, sender] status in - DispatchQueue.main.async { - self?.nowPlayingTapped(sender) - } - } - return - case .restricted: - self.alert(title: "よくわからん事になりました", message: "もしよければ、このアラートがどのような条件で出たか、以下のコードを添えて @imast_ios@mstdn.rinsuki.net までお知らせください。\ncode: MPMediaLibraryAuthorizationStatus is restricted") - return - case .authorized: - break - @unknown default: - self.alert(title: "よくわからん事になりました", message: "もしよければ、このアラートがどのような条件で出たか、以下のコードを添えて @imast_ios@mstdn.rinsuki.net までお知らせください。\ncode: MPMediaLibraryAuthorizationStatus is unknown value") - return - } - guard let nowPlayingMusic = MPMusicPlayerController.systemMusicPlayer.nowPlayingItem else { return } - if nowPlayingMusic.title == nil { - return - } - var nowPlayingText = Defaults.nowplayingFormat - nowPlayingText = nowPlayingText.replacingOccurrences(of: "{title}", with: nowPlayingMusic.title ?? "") - nowPlayingText = nowPlayingText.replacingOccurrences(of: "{artist}", with: nowPlayingMusic.artist ?? "") - nowPlayingText = nowPlayingText.replacingOccurrences(of: "{albumArtist}", with: nowPlayingMusic.albumArtist ?? "") - nowPlayingText = nowPlayingText.replacingOccurrences(of: "{albumTitle}", with: nowPlayingMusic.albumTitle ?? "") - - func finished(_ text: String) { - contentView.textInput.insertText(text) - } - - func checkAppleMusic() -> Bool { - guard Defaults.nowplayingAddAppleMusicUrl else { return false } - let storeId = nowPlayingMusic.playbackStoreID - guard storeId != "0" else { return false } - let region = Locale.current.regionCode ?? "jp" - var request = URLRequest(url: URL(string: "https://itunes.apple.com/lookup?id=\(storeId)&country=\(region)&media=music")!) - request.timeoutInterval = 1.5 - request.addValue(UserAgentString, forHTTPHeaderField: "User-Agent") - Task { @MainActor in - var text = nowPlayingText - do { - let (data, res) = try await URLSession.shared.data(for: request) - struct SearchResultWrapper: Codable { - let results: [SearchResult] - } - struct SearchResult: Codable { - let trackViewUrl: URL - } - let result = try JSONDecoder().decode(SearchResultWrapper.self, from: data) - if let url = result.results.first?.trackViewUrl { - text += " " + url.absoluteString + " " - } - } catch { - // nothing - } - finished(text) - } - return true - } - if !checkAppleMusic() { - finished(nowPlayingText) - } + viewModel.isNSFW.toggle() } @objc func imageSelectButtonTapped(_ sender: UIButton) { - let contentVC = NewPostMediaListViewController(newPostVC: self) + let contentVC = NewPostMediaListViewController(viewModel: viewModel, inline: false) contentVC.modalPresentationStyle = .popover contentVC.preferredContentSize = CGSize(width: 500, height: 100) contentVC.popoverPresentationController?.sourceView = contentView.imageSelectButton @@ -365,7 +383,7 @@ class NewPostViewController: UIViewController, UITextViewDelegate { @objc func openImagePickerDirectly(_ gesture: UILongPressGestureRecognizer) { guard gesture.state == .began else { return } - let contentVC = NewPostMediaListViewController(newPostVC: self) + let contentVC = NewPostMediaListViewController(viewModel: viewModel, inline: false) contentVC.modalPresentationStyle = .popover contentVC.preferredContentSize = CGSize(width: 500, height: 100) contentVC.popoverPresentationController?.sourceView = contentView.imageSelectButton @@ -380,12 +398,12 @@ class NewPostViewController: UIViewController, UITextViewDelegate { func clearContent() { contentView.cwInput.text = "" contentView.textInput.text = "" - media = [] - isNSFW = false + viewModel.media = [] + viewModel.isNSFW = false if Defaults.usingDefaultVisibility { setVisibilityFromUserInfo() } else { - scope = .public + viewModel.visibility = .public } } @@ -393,7 +411,7 @@ class NewPostViewController: UIViewController, UITextViewDelegate { Task { @MainActor in let res = try await self.userToken.getUserInfo(cache: true) if let myScope = MastodonPostVisibility(rawValue: res.source?.privacy ?? "public") { - self.scope = myScope + viewModel.visibility = myScope } } } diff --git a/Sources/iOS/App/Screens/NewPost/NewPostViewModel.swift b/Sources/iOS/App/Screens/NewPost/NewPostViewModel.swift new file mode 100644 index 000000000..2f084684b --- /dev/null +++ b/Sources/iOS/App/Screens/NewPost/NewPostViewModel.swift @@ -0,0 +1,108 @@ +// +// NewPostViewModel.swift +// +// iMast https://github.com/cinderella-project/iMast +// +// Created by user on 2024/02/12. +// +// ------------------------------------------------------------------------ +// +// Copyright 2017-2021 rinsuki and other contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation +import UIKit +import Combine +import iMastiOSCore +import MediaPlayer + +class NewPostViewModel: ObservableObject { + @Published var visibility: MastodonPostVisibility = .public + @Published var isNSFW = false + @Published var media: [UploadableMedia] = [] + + weak var alertPresenter: NewPostViewController? + + @MainActor @objc func insertNowPlayingInfo() { + switch MPMediaLibrary.authorizationStatus() { + case .denied: + alertPresenter?.alert( + title: L10n.Localizable.Error.title, + message: L10n.NewPost.Errors.declineAppleMusicPermission + ) + return + case .notDetermined: + MPMediaLibrary.requestAuthorization { [weak self] status in + DispatchQueue.main.async { + self?.insertNowPlayingInfo() + } + } + return + case .restricted: + alertPresenter?.alert(title: "よくわからん事になりました", message: "もしよければ、このアラートがどのような条件で出たか、以下のコードを添えて @imast_ios@mstdn.rinsuki.net までお知らせください。\ncode: MPMediaLibraryAuthorizationStatus is restricted") + return + case .authorized: + break + @unknown default: + alertPresenter?.alert(title: "よくわからん事になりました", message: "もしよければ、このアラートがどのような条件で出たか、以下のコードを添えて @imast_ios@mstdn.rinsuki.net までお知らせください。\ncode: MPMediaLibraryAuthorizationStatus is unknown value") + return + } + guard let nowPlayingMusic = MPMusicPlayerController.systemMusicPlayer.nowPlayingItem else { return } + if nowPlayingMusic.title == nil { + return + } + var nowPlayingText = Defaults.nowplayingFormat + nowPlayingText = nowPlayingText.replacingOccurrences(of: "{title}", with: nowPlayingMusic.title ?? "") + nowPlayingText = nowPlayingText.replacingOccurrences(of: "{artist}", with: nowPlayingMusic.artist ?? "") + nowPlayingText = nowPlayingText.replacingOccurrences(of: "{albumArtist}", with: nowPlayingMusic.albumArtist ?? "") + nowPlayingText = nowPlayingText.replacingOccurrences(of: "{albumTitle}", with: nowPlayingMusic.albumTitle ?? "") + + func finished(_ text: String) { + alertPresenter?.contentView.textInput.insertText(text) + } + + func checkAppleMusic() -> Bool { + guard Defaults.nowplayingAddAppleMusicUrl else { return false } + let storeId = nowPlayingMusic.playbackStoreID + guard storeId != "0" else { return false } + let region = Locale.current.regionCode ?? "jp" + var request = URLRequest(url: URL(string: "https://itunes.apple.com/lookup?id=\(storeId)&country=\(region)&media=music")!) + request.timeoutInterval = 1.5 + request.addValue(UserAgentString, forHTTPHeaderField: "User-Agent") + Task { @MainActor in + var text = nowPlayingText + do { + let (data, res) = try await URLSession.shared.data(for: request) + struct SearchResultWrapper: Codable { + let results: [SearchResult] + } + struct SearchResult: Codable { + let trackViewUrl: URL + } + let result = try JSONDecoder().decode(SearchResultWrapper.self, from: data) + if let url = result.results.first?.trackViewUrl { + text += " " + url.absoluteString + " " + } + } catch { + // nothing + } + finished(text) + } + return true + } + if !checkAppleMusic() { + finished(nowPlayingText) + } + } +} diff --git a/iMast.xcodeproj/project.pbxproj b/iMast.xcodeproj/project.pbxproj index 2b45d5995..dc3abbe88 100644 --- a/iMast.xcodeproj/project.pbxproj +++ b/iMast.xcodeproj/project.pbxproj @@ -334,6 +334,7 @@ CEF47F702A79350B00D1AEAB /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = CE6554C923C25DDC0084FE1D /* Localizable.strings */; }; CEF47F722A793BC700D1AEAB /* UITextFieldWithInsets.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8FD55C22D06D9300331F15 /* UITextFieldWithInsets.swift */; }; CEF4E2DA2B798E82008E93DD /* UIViewController+showAsWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEF4E2D72B798E4F008E93DD /* UIViewController+showAsWindow.swift */; }; + CEF4E2DF2B79C120008E93DD /* NewPostViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEF4E2DE2B79C120008E93DD /* NewPostViewModel.swift */; }; CEF6C7CE23B0B46D00E0ADA1 /* AddMastodonAccountSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEF6C7CC23B0B43500E0ADA1 /* AddMastodonAccountSheetView.swift */; }; CEF6C7CF23B0B46F00E0ADA1 /* AddMastodonAccountSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEF6C7C923B0B36400E0ADA1 /* AddMastodonAccountSheetViewController.swift */; }; CEF6C7D123B122D200E0ADA1 /* SpacerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEF6C7D023B122D200E0ADA1 /* SpacerView.swift */; }; @@ -940,6 +941,7 @@ CEF47F612A79322000D1AEAB /* swiftgen.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.yaml; path = swiftgen.yml; sourceTree = ""; }; CEF47F662A79338800D1AEAB /* strings_ios.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = strings_ios.swift; sourceTree = ""; }; CEF4E2D72B798E4F008E93DD /* UIViewController+showAsWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+showAsWindow.swift"; sourceTree = ""; }; + CEF4E2DE2B79C120008E93DD /* NewPostViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewPostViewModel.swift; sourceTree = ""; }; CEF6C7C923B0B36400E0ADA1 /* AddMastodonAccountSheetViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddMastodonAccountSheetViewController.swift; sourceTree = ""; }; CEF6C7CC23B0B43500E0ADA1 /* AddMastodonAccountSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddMastodonAccountSheetView.swift; sourceTree = ""; }; CEF6C7D023B122D200E0ADA1 /* SpacerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SpacerView.swift; path = Sources/Mac/Core/SpacerView.swift; sourceTree = SOURCE_ROOT; }; @@ -1913,6 +1915,7 @@ CEE674D828B0561600667E04 /* NewPostView.swift */, 2A492A7E1EB3584500F81E73 /* NewPostViewController.swift */, CEE2E79F208B48E60020F86F /* NewPostMediaListViewController.swift */, + CEF4E2DE2B79C120008E93DD /* NewPostViewModel.swift */, ); path = NewPost; sourceTree = ""; @@ -3432,6 +3435,7 @@ CE54BC7B20E0546E0034B63E /* UserProfileBioViewController.swift in Sources */, CE1002D5200683850041B636 /* ReportError.swift in Sources */, 2A108F8F1ED4A40600BE1404 /* HomeTimelineViewController.swift in Sources */, + CEF4E2DF2B79C120008E93DD /* NewPostViewModel.swift in Sources */, 2A108F931ED55C4F00BE1404 /* LocalTimelineViewController.swift in Sources */, CE272D651F340A7900265C07 /* MastodonPostAbuseViewController.swift in Sources */, CE8AAC5D291780A900AC7FB4 /* UIViewController+resolveUserProfile.swift in Sources */,