Feature: Post and Reply by Threads (#1740)

* refactor: `StatusEditorView`

* feat: post and reply by threads

* Tidy up

* Fixes

---------

Co-authored-by: Thomas Ricouard <ricouard77@gmail.com>
This commit is contained in:
Thai D. V 2023-12-14 14:06:24 +07:00 committed by GitHub
parent d8a686be51
commit 1977b1a572
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 592 additions and 284 deletions

View file

@ -61533,6 +61533,125 @@
}
}
},
"status.editor.follow-up.text.placeholder" : {
"extractionState" : "manual",
"localizations" : {
"be" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Type your follow-up content here."
}
},
"ca" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Type your follow-up content here."
}
},
"de" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Type your follow-up content here."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Type your follow-up content here."
}
},
"en-GB" : {
"stringUnit" : {
"state" : "translated",
"value" : "Type your follow-up content here."
}
},
"es" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Type your follow-up content here."
}
},
"eu" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Type your follow-up content here."
}
},
"fr" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Type your follow-up content here."
}
},
"it" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Type your follow-up content here."
}
},
"ja" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Type your follow-up content here."
}
},
"ko" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Type your follow-up content here."
}
},
"nb" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Type your follow-up content here."
}
},
"nl" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Type your follow-up content here."
}
},
"pl" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Type your follow-up content here."
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Type your follow-up content here."
}
},
"tr" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Type your follow-up content here."
}
},
"uk" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Type your follow-up content here."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Type your follow-up content here."
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Type your follow-up content here."
}
}
}
},
"status.editor.language-select.confirmation.detected-%@" : {
"extractionState" : "manual",
"localizations" : {

View file

@ -12,8 +12,9 @@ struct StatusEditorAccessoryView: View {
@Environment(CurrentInstance.self) private var currentInstance
@Environment(\.colorScheme) private var colorScheme
@FocusState<Bool>.Binding var isSpoilerTextFocused: Bool
var viewModel: StatusEditorViewModel
@FocusState<UUID?>.Binding var isSpoilerTextFocused: UUID?
let focusedSEVM: StatusEditorViewModel
@Binding var followUpSEVMs: [StatusEditorViewModel]
@State private var isDraftsSheetDisplayed: Bool = false
@State private var isLanguageSheetDisplayed: Bool = false
@ -25,7 +26,8 @@ struct StatusEditorAccessoryView: View {
@State private var isCameraPickerPresented: Bool = false
var body: some View {
@Bindable var viewModel = viewModel
@Bindable var viewModel = focusedSEVM
VStack(spacing: 0) {
Divider()
HStack {
@ -83,6 +85,14 @@ struct StatusEditorAccessoryView: View {
.accessibilityLabel("accessibility.editor.button.attach-photo")
.disabled(viewModel.showPoll)
Button {
// all SEVM have the same visibility value
followUpSEVMs.append(StatusEditorViewModel(mode: .new(visibility: focusedSEVM.visibility)))
} label: {
Image(systemName: "arrowshape.turn.up.left.circle.fill")
}
.disabled(!canAddNewSEVM)
Button {
withAnimation {
viewModel.showPoll.toggle()
@ -98,7 +108,7 @@ struct StatusEditorAccessoryView: View {
withAnimation {
viewModel.spoilerOn.toggle()
}
isSpoilerTextFocused.toggle()
isSpoilerTextFocused = viewModel.id
} label: {
Image(systemName: viewModel.spoilerOn ? "exclamationmark.triangle.fill" : "exclamationmark.triangle")
}
@ -180,12 +190,26 @@ struct StatusEditorAccessoryView: View {
}
}
private var canAddNewSEVM: Bool {
guard followUpSEVMs.count < 5 else { return false }
if followUpSEVMs.isEmpty, // there is only mainSEVM on the editor
!focusedSEVM.statusText.string.isEmpty // focusedSEVM is also mainSEVM
{ return true }
if let lastSEVMs = followUpSEVMs.last,
!lastSEVMs.statusText.string.isEmpty
{ return true }
return false
}
private var draftsListView: some View {
DraftsListView(selectedDraft: .init(get: {
nil
}, set: { draft in
if let draft {
viewModel.insertStatusText(text: draft.content)
focusedSEVM.insertStatusText(text: draft.content)
}
}))
}
@ -205,17 +229,17 @@ struct StatusEditorAccessoryView: View {
Button {
Task {
isLoadingAIRequest = true
await viewModel.runOpenAI(prompt: prompt.toRequestPrompt(text: viewModel.statusText.string))
await focusedSEVM.runOpenAI(prompt: prompt.toRequestPrompt(text: focusedSEVM.statusText.string))
isLoadingAIRequest = false
}
} label: {
prompt.label
}
}
if let backup = viewModel.backupStatusText {
if let backup = focusedSEVM.backupStatusText {
Button {
viewModel.replaceTextWith(text: backup.string)
viewModel.backupStatusText = nil
focusedSEVM.replaceTextWith(text: backup.string)
focusedSEVM.backupStatusText = nil
} label: {
Label("status.editor.restore-previous", systemImage: "arrow.uturn.right")
}
@ -268,15 +292,15 @@ struct StatusEditorAccessoryView: View {
name: language.localizedName
).tag(language.isoCode)
Spacer()
if language.isoCode == viewModel.selectedLanguage {
if language.isoCode == focusedSEVM.selectedLanguage {
Image(systemName: "checkmark")
}
}
.listRowBackground(theme.primaryBackgroundColor)
.contentShape(Rectangle())
.onTapGesture {
viewModel.selectedLanguage = language.isoCode
viewModel.hasExplicitlySelectedLanguage = true
focusedSEVM.selectedLanguage = language.isoCode
focusedSEVM.hasExplicitlySelectedLanguage = true
isLanguageSheetDisplayed = false
}
}
@ -285,7 +309,7 @@ struct StatusEditorAccessoryView: View {
private var customEmojisSheet: some View {
NavigationStack {
ScrollView {
ForEach(viewModel.customEmojiContainer) { container in
ForEach(focusedSEVM.customEmojiContainer) { container in
VStack(alignment: .leading) {
Text(container.categoryName)
.font(.scaledFootnote)
@ -308,7 +332,7 @@ struct StatusEditorAccessoryView: View {
}
}
.onTapGesture {
viewModel.insertStatusText(text: " :\(emoji.shortcode): ")
focusedSEVM.insertStatusText(text: " :\(emoji.shortcode): ")
}
}
}
@ -332,7 +356,7 @@ struct StatusEditorAccessoryView: View {
@ViewBuilder
private var characterCountView: some View {
let value = (currentInstance.instance?.configuration?.statuses.maxCharacters ?? 500) + viewModel.statusTextCharacterLength
let value = (currentInstance.instance?.configuration?.statuses.maxCharacters ?? 500) + focusedSEVM.statusTextCharacterLength
Text("\(value)")
.foregroundColor(value < 0 ? .red : .secondary)

View file

@ -105,6 +105,7 @@ struct StatusEditorMediaEditView: View {
}
}
}
.preferredColorScheme(theme.selectedScheme == .dark ? .dark : .light)
}
}

View file

@ -10,7 +10,7 @@ struct StatusEditorMediaView: View {
@Environment(Theme.self) private var theme
@Environment(CurrentInstance.self) private var currentInstance
var viewModel: StatusEditorViewModel
@Binding var editingContainer: StatusEditorMediaContainer?
@Binding var editingMediaContainer: StatusEditorMediaContainer?
@State private var isErrorDisplayed: Bool = false
@ -56,9 +56,9 @@ struct StatusEditorMediaView: View {
private var scrollBottomPadding : CGFloat? = 0
#endif
init(viewModel: StatusEditorViewModel, editingContainer: Binding<StatusEditorMediaContainer?>) {
init(viewModel: StatusEditorViewModel, editingMediaContainer: Binding<StatusEditorMediaContainer?>) {
self.viewModel = viewModel
self._editingContainer = editingContainer
self._editingMediaContainer = editingMediaContainer
}
private func pixel(at index: Int) -> some View {
@ -175,7 +175,7 @@ struct StatusEditorMediaView: View {
if container.mediaAttachment?.url != nil {
if currentInstance.isEditAltTextSupported || !viewModel.mode.isEditing {
Button {
editingContainer = container
editingMediaContainer = container
} label: {
Label(container.mediaAttachment?.description?.isEmpty == false ?
"status.editor.description.edit" : "status.editor.description.add",
@ -211,7 +211,7 @@ struct StatusEditorMediaView: View {
private func makeAltMarker(container: StatusEditorMediaContainer) -> some View {
Button {
editingContainer = container
editingMediaContainer = container
} label: {
Text("status.image.alt-text.abbreviation")
.font(.caption2)

View file

@ -0,0 +1,158 @@
import SwiftUI
import Models
import Env
import DesignSystem
import Accounts
import AppAccount
import Network
@MainActor
struct StatusEditorCoreView: View {
@Bindable var viewModel: StatusEditorViewModel
@Binding var followUpSEVMs: [StatusEditorViewModel]
@Binding var editingMediaContainer: StatusEditorMediaContainer?
@FocusState<UUID?>.Binding var isSpoilerTextFocused: UUID?
@FocusState<StatusEditorFocusState?>.Binding var editorFocusState: StatusEditorFocusState?
let assignedFocusState: StatusEditorFocusState
let isMain: Bool
@Environment(Theme.self) private var theme
@Environment(UserPreferences.self) private var preferences
@Environment(CurrentAccount.self) private var currentAccount
@Environment(AppAccountsManager.self) private var appAccounts
@Environment(Client.self) private var client
#if targetEnvironment(macCatalyst)
@Environment(\.dismissWindow) private var dismissWindow
#else
@Environment(\.dismiss) private var dismiss
#endif
var body: some View {
HStack(spacing: 0) {
if !isMain {
Rectangle()
.fill(theme.tintColor)
.frame(width: 2)
.accessibilityHidden(true)
.padding(.leading, .layoutPadding)
}
VStack(spacing: 0) {
spoilerTextView
VStack(spacing: 0) {
accountHeaderView
textInput
StatusEditorMediaView(viewModel: viewModel, editingMediaContainer: $editingMediaContainer)
embeddedStatus
pollView
}
.padding(.vertical)
Divider()
}
.opacity(editorFocusState == assignedFocusState ? 1 : 0.6)
}
.background(theme.primaryBackgroundColor)
.focused($editorFocusState, equals: assignedFocusState)
.onAppear { setupViewModel() }
}
@ViewBuilder
private var spoilerTextView: some View {
if viewModel.spoilerOn {
TextField("status.editor.spoiler", text: $viewModel.spoilerText)
.focused($isSpoilerTextFocused, equals: viewModel.id)
.padding(.horizontal, .layoutPadding)
.padding(.vertical)
.background(theme.tintColor.opacity(0.20))
}
}
@ViewBuilder
private var accountHeaderView: some View {
if let account = currentAccount.account, !viewModel.mode.isEditing {
HStack {
if viewModel.mode.isInShareExtension {
AppAccountsSelectorView(routerPath: RouterPath(),
accountCreationEnabled: false,
avatarConfig: .status)
} else {
AvatarView(account.avatar, config: AvatarView.FrameConfig.status)
.environment(theme)
.accessibilityHidden(true)
}
VStack(alignment: .leading, spacing: 4) {
StatusEditorPrivacyMenu(visibility: $viewModel.visibility, tint: isMain ? theme.tintColor : .secondary)
.disabled(!isMain)
Text("@\(account.acct)@\(appAccounts.currentClient.server)")
.font(.scaledFootnote)
.foregroundStyle(.secondary)
}
Spacer()
if case let .followUp(id) = assignedFocusState {
Button {
followUpSEVMs.removeAll { $0.id == id }
} label: {
HStack {
Image(systemName: "minus.circle.fill").foregroundStyle(.red)
}
}
}
}
.padding(.horizontal, .layoutPadding)
}
}
private var textInput: some View {
TextView(
$viewModel.statusText,
getTextView: { textView in viewModel.textView = textView }
)
.placeholder(String(localized: isMain ? "status.editor.text.placeholder" : "status.editor.follow-up.text.placeholder"))
.setKeyboardType(preferences.isSocialKeyboardEnabled ? .twitter : .default)
.padding(.horizontal, .layoutPadding)
.padding(.vertical)
}
@ViewBuilder
private var embeddedStatus: some View {
if viewModel.replyToStatus != nil { Divider().padding(.top, 20) }
if let status = viewModel.embeddedStatus ?? viewModel.replyToStatus {
StatusEmbeddedView(status: status, client: client, routerPath: RouterPath())
.padding(.horizontal, .layoutPadding)
.disabled(true)
}
}
@ViewBuilder
private var pollView: some View {
if viewModel.showPoll {
StatusEditorPollView(viewModel: viewModel, showPoll: $viewModel.showPoll)
.padding(.horizontal)
}
}
private func setupViewModel() {
viewModel.client = client
viewModel.currentAccount = currentAccount.account
viewModel.theme = theme
viewModel.preferences = preferences
viewModel.prepareStatusText()
if !client.isAuth {
#if targetEnvironment(macCatalyst)
dismissWindow()
#else
dismiss()
#endif
NotificationCenter.default.post(name: .shareSheetClose, object: nil)
}
Task { await viewModel.fetchCustomEmojis() }
}
}

View file

@ -0,0 +1,5 @@
import SwiftUI
enum StatusEditorFocusState: Hashable {
case main, followUp(index: UUID)
}

View file

@ -0,0 +1,32 @@
import SwiftUI
import Models
struct StatusEditorPrivacyMenu: View {
@Binding var visibility: Models.Visibility
let tint: Color
var body: some View {
Menu {
ForEach(Models.Visibility.allCases, id: \.self) { vis in
Button { self.visibility = vis } label: {
Label(vis.title, systemImage: vis.iconName)
}
}
} label: {
HStack {
Label(visibility.title, systemImage: visibility.iconName)
.accessibilityLabel("accessibility.editor.privacy.label")
.accessibilityValue(visibility.title)
.accessibilityHint("accessibility.editor.privacy.hint")
Image(systemName: "chevron.down")
}
.font(.scaledFootnote)
.padding(4)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(tint, lineWidth: 1)
)
}
}
}

View file

@ -0,0 +1,136 @@
import SwiftUI
import Env
import Models
import StoreKit
@MainActor
struct StatusEditorToolbarItems: ToolbarContent {
@State private var isLanguageConfirmPresented = false
@State private var isDismissAlertPresented: Bool = false
let mainSEVM: StatusEditorViewModel
let followUpSEVMs: [StatusEditorViewModel]
@Environment(\.modelContext) private var context
@Environment(UserPreferences.self) private var preferences
#if targetEnvironment(macCatalyst)
@Environment(\.dismissWindow) private var dismissWindow
#else
@Environment(\.dismiss) private var dismiss
#endif
var body: some ToolbarContent {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
Task {
mainSEVM.evaluateLanguages()
if preferences.autoDetectPostLanguage, let _ = mainSEVM.languageConfirmationDialogLanguages {
isLanguageConfirmPresented = true
} else {
await postAllStatus()
}
}
} label: {
if mainSEVM.isPosting {
ProgressView()
} else {
Text("status.action.post").bold()
}
}
.disabled(!mainSEVM.canPost)
.keyboardShortcut(.return, modifiers: .command)
.confirmationDialog("", isPresented: $isLanguageConfirmPresented, actions: {
languageConfirmationDialog
})
}
ToolbarItem(placement: .navigationBarLeading) {
Button {
if mainSEVM.shouldDisplayDismissWarning {
isDismissAlertPresented = true
} else {
close()
NotificationCenter.default.post(name: .shareSheetClose,
object: nil)
}
} label: {
Text("action.cancel")
}
.keyboardShortcut(.cancelAction)
.confirmationDialog(
"",
isPresented: $isDismissAlertPresented,
actions: {
Button("status.draft.delete", role: .destructive) {
close()
NotificationCenter.default.post(name: .shareSheetClose,
object: nil)
}
Button("status.draft.save") {
context.insert(Draft(content: mainSEVM.statusText.string))
close()
NotificationCenter.default.post(name: .shareSheetClose,
object: nil)
}
Button("action.cancel", role: .cancel) {}
}
)
}
}
@discardableResult
private func postStatus(with model: StatusEditorViewModel, isMainPost: Bool) async -> Status? {
let status = await model.postStatus()
if status != nil && isMainPost {
close()
SoundEffectManager.shared.playSound(.tootSent)
NotificationCenter.default.post(name: .shareSheetClose, object: nil)
#if !targetEnvironment(macCatalyst)
if !mainSEVM.mode.isInShareExtension, !preferences.requestedReview {
if let scene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene {
SKStoreReviewController.requestReview(in: scene)
}
preferences.requestedReview = true
}
#endif
}
return status
}
private func postAllStatus() async {
guard let openingPost = await postStatus(with: mainSEVM, isMainPost: true) else { return }
for p in followUpSEVMs {
p.mode = .replyTo(status: openingPost)
await postStatus(with: p, isMainPost: false)
}
}
#if targetEnvironment(macCatalyst)
private func close() { dismissWindow() }
#else
private func close() { dismiss() }
#endif
@ViewBuilder
private var languageConfirmationDialog: some View {
if let (detected: detected, selected: selected) = mainSEVM.languageConfirmationDialogLanguages,
let detectedLong = Locale.current.localizedString(forLanguageCode: detected),
let selectedLong = Locale.current.localizedString(forLanguageCode: selected)
{
Button("status.editor.language-select.confirmation.detected-\(detectedLong)") {
mainSEVM.selectedLanguage = detected
Task { await postAllStatus() }
}
Button("status.editor.language-select.confirmation.selected-\(selectedLong)") {
mainSEVM.selectedLanguage = selected
Task { await postAllStatus() }
}
Button("action.cancel", role: .cancel) {
mainSEVM.languageConfirmationDialogLanguages = nil
}
} else {
EmptyView()
}
}
}

View file

@ -14,290 +14,121 @@ import UIKit
@MainActor
public struct StatusEditorView: View {
@Environment(AppAccountsManager.self) private var appAccounts
@Environment(UserPreferences.self) private var preferences
@Environment(Theme.self) private var theme
@Environment(Client.self) private var client
@Environment(CurrentAccount.self) private var currentAccount
@Environment(\.dismiss) private var dismiss
@Environment(\.dismissWindow) private var dismissWindow
@Environment(\.modelContext) private var context
@Environment(Theme.self) private var theme
@State private var viewModel: StatusEditorViewModel
@FocusState private var isSpoilerTextFocused: Bool
@State private var mainSEVM: StatusEditorViewModel
@State private var followUpSEVMs: [StatusEditorViewModel] = []
@FocusState private var isSpoilerTextFocused: UUID? // connect CoreEditor and StatusEditorAccessoryView
@State private var editingMediaContainer: StatusEditorMediaContainer?
@State private var scrollID: UUID?
@State private var isDismissAlertPresented: Bool = false
@State private var isLanguageConfirmPresented = false
@FocusState private var editorFocusState: StatusEditorFocusState?
private var focusedSEVM: StatusEditorViewModel {
if case let .followUp(id) = editorFocusState,
let sevm = followUpSEVMs.first(where: { $0.id == id })
{ return sevm }
@State private var editingContainer: StatusEditorMediaContainer?
return mainSEVM
}
public init(mode: StatusEditorViewModel.Mode) {
_viewModel = .init(initialValue: .init(mode: mode))
_mainSEVM = State(initialValue: StatusEditorViewModel(mode: mode))
}
public var body: some View {
@Bindable var focusedSEVM = self.focusedSEVM
NavigationStack {
ZStack(alignment: .bottom) {
ScrollView {
Divider()
spoilerTextView
VStack(spacing: 12) {
accountHeaderView
.padding(.horizontal, .layoutPadding)
TextView($viewModel.statusText,
getTextView: { textView in
viewModel.textView = textView
})
.placeholder(String(localized: "status.editor.text.placeholder"))
.setKeyboardType(preferences.isSocialKeyboardEnabled ? .twitter : .default)
.padding(.horizontal, .layoutPadding)
StatusEditorMediaView(viewModel: viewModel,
editingContainer: $editingContainer)
if let status = viewModel.embeddedStatus {
StatusEmbeddedView(status: status, client: client, routerPath: RouterPath())
.padding(.horizontal, .layoutPadding)
.disabled(true)
} else if let status = viewModel.replyToStatus {
Divider()
.padding(.top, 20)
StatusEmbeddedView(status: status, client: client, routerPath: RouterPath())
.padding(.horizontal, .layoutPadding)
.disabled(true)
}
if viewModel.showPoll {
StatusEditorPollView(viewModel: viewModel, showPoll: $viewModel.showPoll)
.padding(.horizontal)
}
Spacer()
}
.padding(.top, 8)
.padding(.bottom, 40)
}
.accessibilitySortPriority(1) // Ensure that all elements inside the `ScrollView` occur earlier than the accessory views
.padding(.top, 1) // hacky fix for weird SwiftUI scrollView bug when adding padding
.padding(.bottom, 48)
VStack(alignment: .leading, spacing: 0) {
StatusEditorAutoCompleteView(viewModel: viewModel)
StatusEditorAccessoryView(isSpoilerTextFocused: $isSpoilerTextFocused,
viewModel: viewModel)
}
}
.onDrop(of: StatusEditorUTTypeSupported.types(), delegate: viewModel)
.onAppear {
viewModel.client = client
viewModel.currentAccount = currentAccount.account
viewModel.theme = theme
viewModel.preferences = preferences
viewModel.prepareStatusText()
if !client.isAuth {
close()
NotificationCenter.default.post(name: .shareSheetClose,
object: nil)
}
Task {
await viewModel.fetchCustomEmojis()
}
}
.onChange(of: currentAccount.account?.id) {
viewModel.currentAccount = currentAccount.account
}
.background(theme.primaryBackgroundColor)
.navigationTitle(viewModel.mode.title)
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.visible, for: .navigationBar)
.alert("status.error.posting.title",
isPresented: $viewModel.showPostingErrorAlert,
actions: {
Button("OK") {}
}, message: {
Text(viewModel.postingError ?? "")
})
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
Task {
viewModel.evaluateLanguages()
if preferences.autoDetectPostLanguage, let _ = viewModel.languageConfirmationDialogLanguages {
isLanguageConfirmPresented = true
} else {
await postStatus()
}
}
} label: {
if viewModel.isPosting {
ProgressView()
} else {
Text("status.action.post").bold()
}
}
.disabled(!viewModel.canPost)
.keyboardShortcut(.return, modifiers: .command)
.confirmationDialog("", isPresented: $isLanguageConfirmPresented, actions: {
languageConfirmationDialog
})
}
ToolbarItem(placement: .navigationBarLeading) {
Button {
if viewModel.shouldDisplayDismissWarning {
isDismissAlertPresented = true
} else {
close()
NotificationCenter.default.post(name: .shareSheetClose,
object: nil)
}
} label: {
Text("action.cancel")
}
.keyboardShortcut(.cancelAction)
.confirmationDialog(
"",
isPresented: $isDismissAlertPresented,
actions: {
Button("status.draft.delete", role: .destructive) {
close()
NotificationCenter.default.post(name: .shareSheetClose,
object: nil)
}
Button("status.draft.save") {
context.insert(Draft(content: viewModel.statusText.string))
close()
NotificationCenter.default.post(name: .shareSheetClose,
object: nil)
}
Button("action.cancel", role: .cancel) {}
}
ScrollView {
VStackLayout(spacing: 0) {
StatusEditorCoreView(
viewModel: mainSEVM,
followUpSEVMs: $followUpSEVMs,
editingMediaContainer: $editingMediaContainer,
isSpoilerTextFocused: $isSpoilerTextFocused,
editorFocusState: $editorFocusState,
assignedFocusState: .main,
isMain: true
)
}
}
}
.sheet(item: $editingContainer) { container in
StatusEditorMediaEditView(viewModel: viewModel, container: container)
.preferredColorScheme(theme.selectedScheme == .dark ? .dark : .light)
}
.interactiveDismissDisabled(viewModel.shouldDisplayDismissWarning)
.onChange(of: appAccounts.currentClient) { _, newValue in
if viewModel.mode.isInShareExtension {
currentAccount.setClient(client: newValue)
viewModel.client = newValue
}
}
}
.id(mainSEVM.id)
@ViewBuilder
private var languageConfirmationDialog: some View {
if let (detected: detected, selected: selected) = viewModel.languageConfirmationDialogLanguages,
let detectedLong = Locale.current.localizedString(forLanguageCode: detected),
let selectedLong = Locale.current.localizedString(forLanguageCode: selected)
{
Button("status.editor.language-select.confirmation.detected-\(detectedLong)") {
viewModel.selectedLanguage = detected
Task {
await postStatus()
}
}
Button("status.editor.language-select.confirmation.selected-\(selectedLong)") {
viewModel.selectedLanguage = selected
Task {
await postStatus()
}
}
Button("action.cancel", role: .cancel) {
viewModel.languageConfirmationDialogLanguages = nil
}
} else {
EmptyView()
}
}
ForEach(followUpSEVMs) { sevm in
@Bindable var sevm: StatusEditorViewModel = sevm
private func postStatus() async {
let status = await viewModel.postStatus()
if status != nil {
close()
SoundEffectManager.shared.playSound(.tootSent)
NotificationCenter.default.post(name: .shareSheetClose,
object: nil)
#if !targetEnvironment(macCatalyst)
if !viewModel.mode.isInShareExtension, !preferences.requestedReview {
if let scene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene {
SKStoreReviewController.requestReview(in: scene)
StatusEditorCoreView(
viewModel: sevm,
followUpSEVMs: $followUpSEVMs,
editingMediaContainer: $editingMediaContainer,
isSpoilerTextFocused: $isSpoilerTextFocused,
editorFocusState: $editorFocusState,
assignedFocusState: .followUp(index: sevm.id),
isMain: false
)
.id(sevm.id)
}
}
preferences.requestedReview = true
.scrollTargetLayout()
}
#endif
}
}
@ViewBuilder
private var spoilerTextView: some View {
if viewModel.spoilerOn {
VStack {
TextField("status.editor.spoiler", text: $viewModel.spoilerText)
.focused($isSpoilerTextFocused)
.padding(.horizontal, .layoutPadding)
.scrollPosition(id: $scrollID, anchor: .top)
.animation(.bouncy(duration: 0.3), value: editorFocusState)
.animation(.bouncy(duration: 0.3), value: followUpSEVMs)
.background(Color.primaryBackground)
.safeAreaInset(edge: .bottom) {
StatusEditorAutoCompleteView(viewModel: focusedSEVM)
}
.frame(height: 35)
.background(theme.tintColor.opacity(0.20))
.offset(y: -8)
}
}
@ViewBuilder
private var accountHeaderView: some View {
if let account = currentAccount.account, !viewModel.mode.isEditing {
HStack {
if viewModel.mode.isInShareExtension {
AppAccountsSelectorView(routerPath: RouterPath(),
accountCreationEnabled: false,
avatarConfig: .status)
} else {
AvatarView(account.avatar, config: AvatarView.FrameConfig.status)
.environment(theme)
.accessibilityHidden(true)
}
VStack(alignment: .leading, spacing: 4) {
privacyMenu
Text("@\(account.acct)@\(appAccounts.currentClient.server)")
.font(.scaledFootnote)
.foregroundStyle(.secondary)
}
Spacer()
.safeAreaInset(edge: .bottom) {
StatusEditorAccessoryView(isSpoilerTextFocused: $isSpoilerTextFocused, focusedSEVM: focusedSEVM, followUpSEVMs: $followUpSEVMs)
}
}
}
private var privacyMenu: some View {
Menu {
Section("status.editor.visibility") {
ForEach(Models.Visibility.allCases, id: \.self) { visibility in
Button {
viewModel.visibility = visibility
} label: {
Label(visibility.title, systemImage: visibility.iconName)
.accessibilitySortPriority(1) // Ensure that all elements inside the `ScrollView` occur earlier than the accessory views
.navigationTitle(focusedSEVM.mode.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar { StatusEditorToolbarItems(mainSEVM: mainSEVM, followUpSEVMs: followUpSEVMs) }
.toolbarBackground(.visible, for: .navigationBar)
.alert(
"status.error.posting.title",
isPresented: $focusedSEVM.showPostingErrorAlert,
actions: {
Button("OK") {}
}, message: {
Text(mainSEVM.postingError ?? "")
})
.interactiveDismissDisabled(mainSEVM.shouldDisplayDismissWarning)
.onChange(of: appAccounts.currentClient) { _, newValue in
if mainSEVM.mode.isInShareExtension {
currentAccount.setClient(client: newValue)
mainSEVM.client = newValue
for post in followUpSEVMs {
post.client = newValue
}
}
}
} label: {
HStack {
Label(viewModel.visibility.title, systemImage: viewModel.visibility.iconName)
.accessibilityLabel("accessibility.editor.privacy.label")
.accessibilityValue(viewModel.visibility.title)
.accessibilityHint("accessibility.editor.privacy.hint")
Image(systemName: "chevron.down")
.onDrop(of: StatusEditorUTTypeSupported.types(), delegate: focusedSEVM)
.onChange(of: currentAccount.account?.id) {
mainSEVM.currentAccount = currentAccount.account
for p in followUpSEVMs {
p.currentAccount = mainSEVM.currentAccount
}
}
.onChange(of: mainSEVM.visibility) {
for p in followUpSEVMs {
p.visibility = mainSEVM.visibility
}
}
.onChange(of: followUpSEVMs.count) { oldValue, newValue in
if oldValue < newValue {
Task {
try? await Task.sleep(for: .seconds(0.1))
withAnimation(.bouncy(duration: 0.5)) {
scrollID = followUpSEVMs.last?.id
}
}
}
}
.font(.scaledFootnote)
.padding(4)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(theme.tintColor, lineWidth: 1)
)
}
}
private func close() {
#if targetEnvironment(macCatalyst)
dismissWindow()
#else
dismiss()
#endif
.sheet(item: $editingMediaContainer) { container in
StatusEditorMediaEditView(viewModel: focusedSEVM, container: container)
}
}
}

View file

@ -8,7 +8,9 @@ import PhotosUI
import SwiftUI
@MainActor
@Observable public class StatusEditorViewModel: NSObject {
@Observable public class StatusEditorViewModel: NSObject, Identifiable {
public let id = UUID()
var mode: Mode
var client: Client?