import DesignSystem import EmojiText import Env import Foundation import Models import Network import SwiftUI @MainActor public struct StatusRowView: View { @Environment(\.openWindow) private var openWindow @Environment(\.isInCaptureMode) private var isInCaptureMode: Bool @Environment(\.redactionReasons) private var reasons @Environment(\.isCompact) private var isCompact: Bool @Environment(\.accessibilityVoiceOverEnabled) private var accessibilityVoiceOverEnabled @Environment(\.isStatusFocused) private var isFocused @Environment(\.indentationLevel) private var indentationLevel @Environment(QuickLook.self) private var quickLook @Environment(Theme.self) private var theme @Environment(Client.self) private var client @State private var viewModel: StatusRowViewModel @State private var showSelectableText: Bool = false @State private var isBlockConfirmationPresented = false public enum Context { case timeline, detail } private let context: Context public init(viewModel: StatusRowViewModel, context: Context = .timeline) { self._viewModel = .init(initialValue: viewModel) self.context = context } var contextMenu: some View { StatusRowContextMenu(viewModel: viewModel, showTextForSelection: $showSelectableText, isBlockConfirmationPresented: $isBlockConfirmationPresented) } public var body: some View { HStack(spacing: 0) { if !isCompact { HStack(spacing: 3) { ForEach(0 ..< indentationLevel, id: \.self) { level in Rectangle() .fill(theme.tintColor) .frame(width: 2) .accessibilityHidden(true) .opacity((indentationLevel == level + 1) ? 1 : 0.15) } } if indentationLevel > 0 { Spacer(minLength: 8) } } VStack(alignment: .leading, spacing: .statusComponentSpacing) { if viewModel.isFiltered, let filter = viewModel.filter { switch filter.filter.filterAction { case .warn: makeFilterView(filter: filter.filter) case .hide: EmptyView() } } else { if !isCompact && context != .detail { Group { StatusRowTagView(viewModel: viewModel) StatusRowReblogView(viewModel: viewModel) StatusRowReplyView(viewModel: viewModel) } .padding(.leading, theme.avatarPosition == .top ? 0 : AvatarView.FrameConfig.status.width + .statusColumnsSpacing) } HStack(alignment: .top, spacing: .statusColumnsSpacing) { if !isCompact, theme.avatarPosition == .leading { Button { viewModel.navigateToAccountDetail(account: viewModel.finalStatus.account) } label: { AvatarView(viewModel.finalStatus.account.avatar) } } VStack(alignment: .leading, spacing: .statusComponentSpacing) { if !isCompact { StatusRowHeaderView(viewModel: viewModel) } StatusRowContentView(viewModel: viewModel) .contentShape(Rectangle()) .onTapGesture { guard !isFocused else { return } viewModel.navigateToDetail() } .accessibilityActions { if isFocused, viewModel.showActions { accessibilityActions } } if !reasons.contains(.placeholder), viewModel.showActions, isFocused || theme.statusActionsDisplay != .none, !isInCaptureMode { StatusRowActionsView(isBlockConfirmationPresented: $isBlockConfirmationPresented, viewModel: viewModel) .tint(isFocused ? theme.tintColor : .gray) } if isFocused, !isCompact { StatusRowDetailView(viewModel: viewModel) } } } } } .padding(.init(top: isCompact ? 6 : 12, leading: 0, bottom: isFocused ? 12 : 6, trailing: 0)) } .onAppear { if !reasons.contains(.placeholder) { if !isCompact, viewModel.embeddedStatus == nil { Task { await viewModel.loadEmbeddedStatus() } } } } .contextMenu { contextMenu .onAppear { Task { await viewModel.loadAuthorRelationship() } } } .swipeActions(edge: .trailing) { // The actions associated with the swipes are exposed as custom accessibility actions and there is no way to remove them. if !isCompact, accessibilityVoiceOverEnabled == false { StatusRowSwipeView(viewModel: viewModel, mode: .trailing) } } .swipeActions(edge: .leading) { // The actions associated with the swipes are exposed as custom accessibility actions and there is no way to remove them. if !isCompact, accessibilityVoiceOverEnabled == false { StatusRowSwipeView(viewModel: viewModel, mode: .leading) } } #if os(visionOS) .listRowBackground(RoundedRectangle(cornerRadius: 8) .foregroundStyle(.background).hoverEffect()) .listRowHoverEffectDisabled() #else .listRowBackground(viewModel.highlightRowColor) #endif .listRowInsets(.init(top: 0, leading: .layoutPadding, bottom: 0, trailing: .layoutPadding)) .accessibilityElement(children: isFocused ? .contain : .combine) .accessibilityLabel(isFocused == false && accessibilityVoiceOverEnabled ? StatusRowAccessibilityLabel(viewModel: viewModel).finalLabel() : Text("")) .accessibilityHidden(viewModel.filter?.filter.filterAction == .hide) .accessibilityAction { guard !isFocused else { return } viewModel.navigateToDetail() } .accessibilityActions { if !isFocused, viewModel.showActions, accessibilityVoiceOverEnabled { accessibilityActions } } .background { Color.clear .contentShape(Rectangle()) .onTapGesture { guard !isFocused else { return } viewModel.navigateToDetail() } } .overlay { if viewModel.isLoadingRemoteContent { remoteContentLoadingView } } .alert(isPresented: $viewModel.showDeleteAlert, content: { Alert( title: Text("status.action.delete.confirm.title"), message: Text("status.action.delete.confirm.message"), primaryButton: .destructive( Text("status.action.delete")) { Task { await viewModel.delete() } }, secondaryButton: .cancel() ) }) .confirmationDialog("", isPresented: $isBlockConfirmationPresented) { Button("account.action.block", role: .destructive) { Task { do { let operationAccount = viewModel.status.reblog?.account ?? viewModel.status.account viewModel.authorRelationship = try await client.post(endpoint: Accounts.block(id: operationAccount.id)) } catch { } } } } .alignmentGuide(.listRowSeparatorLeading) { _ in -100 } .sheet(isPresented: $showSelectableText) { let content = viewModel.status.reblog?.content.asSafeMarkdownAttributedString ?? viewModel.status.content.asSafeMarkdownAttributedString SelectTextView(content: content) } .environment( StatusDataControllerProvider.shared.dataController(for: viewModel.finalStatus, client: viewModel.client) ) } @ViewBuilder private var accessibilityActions: some View { // Add reply and quote, which are lost when the swipe actions are removed Button("status.action.reply") { HapticManager.shared.fireHaptic(.notification(.success)) viewModel.routerPath.presentedSheet = .replyToStatusEditor(status: viewModel.status) } Button("settings.swipeactions.status.action.quote") { HapticManager.shared.fireHaptic(.notification(.success)) viewModel.routerPath.presentedSheet = .quoteStatusEditor(status: viewModel.status) } .disabled(viewModel.status.visibility == .direct || viewModel.status.visibility == .priv) if viewModel.finalStatus.mediaAttachments.isEmpty == false { Button("accessibility.status.media-viewer-action.label") { HapticManager.shared.fireHaptic(.notification(.success)) let attachments = viewModel.finalStatus.mediaAttachments #if targetEnvironment(macCatalyst) || os(visionOS) openWindow(value: WindowDestinationMedia.mediaViewer( attachments: attachments, selectedAttachment: attachments[0] )) #else quickLook.prepareFor(selectedMediaAttachment: attachments[0], mediaAttachments: attachments) #endif } } Button(viewModel.displaySpoiler ? "status.show-more" : "status.show-less") { withAnimation { viewModel.displaySpoiler.toggle() } } Button("@\(viewModel.status.account.username)") { HapticManager.shared.fireHaptic(.notification(.success)) viewModel.routerPath.navigate(to: .accountDetail(id: viewModel.status.account.id)) } // Add a reference to the post creator if viewModel.status.account != viewModel.finalStatus.account { Button("@\(viewModel.finalStatus.account.username)") { HapticManager.shared.fireHaptic(.notification(.success)) viewModel.routerPath.navigate(to: .accountDetail(id: viewModel.finalStatus.account.id)) } } // Add in each detected link in the content ForEach(viewModel.finalStatus.content.links) { link in switch link.type { case .url: if UIApplication.shared.canOpenURL(link.url) { Button("accessibility.tabs.timeline.content-link-\(link.title)") { HapticManager.shared.fireHaptic(.notification(.success)) _ = viewModel.routerPath.handle(url: link.url) } } case .hashtag: Button("accessibility.tabs.timeline.content-hashtag-\(link.title)") { HapticManager.shared.fireHaptic(.notification(.success)) _ = viewModel.routerPath.handle(url: link.url) } case .mention: Button("\(link.title)") { HapticManager.shared.fireHaptic(.notification(.success)) _ = viewModel.routerPath.handle(url: link.url) } } } } private func makeFilterView(filter: Filter) -> some View { HStack { Text("status.filter.filtered-by-\(filter.title)") Button { withAnimation { viewModel.isFiltered = false } } label: { Text("status.filter.show-anyway") .foregroundStyle(theme.tintColor) } .buttonStyle(.plain) } .onTapGesture { withAnimation { viewModel.isFiltered = false } } .accessibilityAction { viewModel.isFiltered = false } } private var remoteContentLoadingView: some View { ZStack(alignment: .center) { VStack { Spacer() HStack { Spacer() ProgressView() Spacer() } Spacer() } } .background(Color.black.opacity(0.40)) .transition(.opacity) } } #Preview { List { StatusRowView(viewModel: .init(status: .placeholder(), client: .init(server: ""), routerPath: RouterPath()), context: .timeline) StatusRowView(viewModel: .init(status: .placeholder(), client: .init(server: ""), routerPath: RouterPath()), context: .timeline) StatusRowView(viewModel: .init(status: .placeholder(), client: .init(server: ""), routerPath: RouterPath()), context: .timeline) }.listStyle(.plain) .environment(RouterPath()) .environment(Client(server: "")) .environment(CurrentAccount.shared) .environment(UserPreferences.shared) .environment(CurrentInstance.shared) .environment(Theme.shared) .environment(PushNotificationsService.shared) .environment(QuickLook.shared) }