mirror of
https://github.com/Dimillian/IceCubesApp.git
synced 2024-06-26 17:10:36 +00:00
Threads/replies are now shown more clearly. Each reply has an indentation level (and therefore the number of vertical lines) one more than its direct parent. This leads to siblings having the same indentation level. It makes understanding somewhat complex thread structures way easier. Previously, a reply was only indented if it came directly after its parent. If a toot had more than one reply, the structure was nearly indecipherable, as it wasn't clear which the parent post of the second (or later) toot was. An example is "https://mastodon.social/@mhoye/110452462852364819" and all of its replies. Signed-off-by: Paul Schuetz <pa.schuetz@web.de>
420 lines
14 KiB
Swift
420 lines
14 KiB
Swift
import DesignSystem
|
|
import EmojiText
|
|
import Env
|
|
import Foundation
|
|
import Models
|
|
import Network
|
|
import Shimmer
|
|
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
|
|
|
|
@State private var viewModel: StatusRowViewModel
|
|
|
|
public init(viewModel: StatusRowViewModel) {
|
|
_viewModel = .init(initialValue: viewModel)
|
|
}
|
|
|
|
var contextMenu: some View {
|
|
StatusRowContextMenu(viewModel: viewModel)
|
|
}
|
|
|
|
public var body: some View {
|
|
HStack(spacing: 0) {
|
|
HStack(spacing: 3) {
|
|
ForEach(0..<indentationLevel, id: \.self) {_ in
|
|
Rectangle()
|
|
.fill(theme.tintColor)
|
|
.frame(width: 2)
|
|
.accessibilityHidden(true)
|
|
}
|
|
}
|
|
if indentationLevel > 0 {
|
|
Spacer(minLength: 8)
|
|
}
|
|
VStack(alignment: .leading) {
|
|
if viewModel.isFiltered, let filter = viewModel.filter {
|
|
switch filter.filter.filterAction {
|
|
case .warn:
|
|
makeFilterView(filter: filter.filter)
|
|
case .hide:
|
|
EmptyView()
|
|
}
|
|
} else {
|
|
if !isCompact, theme.avatarPosition == .leading {
|
|
Group {
|
|
StatusRowReblogView(viewModel: viewModel)
|
|
StatusRowReplyView(viewModel: viewModel)
|
|
}
|
|
.padding(.leading, AvatarView.Size.status.size.width + .statusColumnsSpacing)
|
|
}
|
|
HStack(alignment: .top, spacing: .statusColumnsSpacing) {
|
|
if !isCompact,
|
|
theme.avatarPosition == .leading
|
|
{
|
|
Button {
|
|
viewModel.navigateToAccountDetail(account: viewModel.finalStatus.account)
|
|
} label: {
|
|
AvatarView(url: viewModel.finalStatus.account.avatar, size: .status)
|
|
}
|
|
}
|
|
VStack(alignment: .leading) {
|
|
if !isCompact, theme.avatarPosition == .top {
|
|
StatusRowReblogView(viewModel: viewModel)
|
|
StatusRowReplyView(viewModel: viewModel)
|
|
}
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
if !isCompact {
|
|
StatusRowHeaderView(viewModel: viewModel)
|
|
}
|
|
StatusRowContentView(viewModel: viewModel)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
guard !isFocused else { return }
|
|
viewModel.navigateToDetail()
|
|
}
|
|
.accessibilityActions {
|
|
if isFocused, viewModel.showActions {
|
|
accessibilityActions
|
|
}
|
|
}
|
|
}
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
if viewModel.showActions, isFocused || theme.statusActionsDisplay != .none, !isInCaptureMode {
|
|
StatusRowActionsView(viewModel: viewModel)
|
|
.padding(.top, 8)
|
|
.tint(isFocused ? theme.tintColor : .gray)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
guard !isFocused else { return }
|
|
viewModel.navigateToDetail()
|
|
}
|
|
}
|
|
|
|
if isFocused, !isCompact {
|
|
StatusRowDetailView(viewModel: viewModel)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.onAppear {
|
|
viewModel.markSeen()
|
|
if reasons.isEmpty {
|
|
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)
|
|
}
|
|
}
|
|
.listRowBackground(viewModel.highlightRowColor)
|
|
.listRowInsets(.init(top: 12,
|
|
leading: .layoutPadding,
|
|
bottom: 12,
|
|
trailing: .layoutPadding))
|
|
.accessibilityElement(children: isFocused ? .contain : .combine)
|
|
.accessibilityLabel(isFocused == false && accessibilityVoiceOverEnabled
|
|
? CombinedAccessibilityLabel(viewModel: viewModel).finalLabel() : Text(""))
|
|
.accessibilityHidden(viewModel.filter?.filter.filterAction == .hide)
|
|
.accessibilityAction {
|
|
guard !isFocused else { return }
|
|
viewModel.navigateToDetail()
|
|
}
|
|
.accessibilityActions {
|
|
if isFocused == false, viewModel.showActions {
|
|
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()
|
|
)
|
|
})
|
|
.alignmentGuide(.listRowSeparatorLeading) { _ in
|
|
-100
|
|
}
|
|
.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 ProcessInfo.processInfo.isMacCatalystApp {
|
|
openWindow(value: WindowDestinationMedia.mediaViewer(attachments: attachments,
|
|
selectedAttachment: attachments[0]))
|
|
} else {
|
|
quickLook.prepareFor(selectedMediaAttachment: attachments[0], mediaAttachments: attachments)
|
|
}
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
.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)
|
|
}
|
|
}
|
|
|
|
/// A utility that creates a suitable combined accessibility label for a `StatusRowView` that is not focused.
|
|
@MainActor
|
|
private struct CombinedAccessibilityLabel {
|
|
let viewModel: StatusRowViewModel
|
|
|
|
var hasSpoiler: Bool {
|
|
viewModel.displaySpoiler && viewModel.finalStatus.spoilerText.asRawText.isEmpty == false
|
|
}
|
|
|
|
var isReply: Bool {
|
|
if let accountId = viewModel.status.inReplyToAccountId, viewModel.status.mentions.contains(where: { $0.id == accountId }) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
var isBoost: Bool {
|
|
viewModel.status.reblog != nil
|
|
}
|
|
|
|
var filter: Filter? {
|
|
guard viewModel.isFiltered else {
|
|
return nil
|
|
}
|
|
return viewModel.filter?.filter
|
|
}
|
|
|
|
func finalLabel() -> Text {
|
|
if let filter {
|
|
switch filter.filterAction {
|
|
case .warn:
|
|
Text("status.filter.filtered-by-\(filter.title)")
|
|
case .hide:
|
|
Text("")
|
|
}
|
|
} else {
|
|
userNamePreamble() +
|
|
Text(hasSpoiler
|
|
? viewModel.finalStatus.spoilerText.asRawText
|
|
: viewModel.finalStatus.content.asRawText
|
|
) +
|
|
Text(hasSpoiler
|
|
? "status.editor.spoiler"
|
|
: ""
|
|
) + Text(", ") +
|
|
pollText() +
|
|
imageAltText() +
|
|
Text(viewModel.finalStatus.createdAt.relativeFormatted) + Text(", ") +
|
|
Text("status.summary.n-replies \(viewModel.finalStatus.repliesCount)") + Text(", ") +
|
|
Text("status.summary.n-boosts \(viewModel.finalStatus.reblogsCount)") + Text(", ") +
|
|
Text("status.summary.n-favorites \(viewModel.finalStatus.favouritesCount)")
|
|
}
|
|
}
|
|
|
|
func userNamePreamble() -> Text {
|
|
switch (isReply, isBoost) {
|
|
case (true, false):
|
|
Text("accessibility.status.a-replied-to-\(finalUserDisplayName())") + Text(" ")
|
|
case (_, true):
|
|
Text("accessibility.status.a-boosted-b-\(userDisplayName())-\(finalUserDisplayName())") + Text(", ")
|
|
default:
|
|
Text(userDisplayName()) + Text(", ")
|
|
}
|
|
}
|
|
|
|
func userDisplayName() -> String {
|
|
viewModel.status.account.displayNameWithoutEmojis.count < 4
|
|
? viewModel.status.account.safeDisplayName
|
|
: viewModel.status.account.displayNameWithoutEmojis
|
|
}
|
|
|
|
func finalUserDisplayName() -> String {
|
|
viewModel.finalStatus.account.displayNameWithoutEmojis.count < 4
|
|
? viewModel.finalStatus.account.safeDisplayName
|
|
: viewModel.finalStatus.account.displayNameWithoutEmojis
|
|
}
|
|
|
|
func imageAltText() -> Text {
|
|
let descriptions = viewModel.finalStatus.mediaAttachments
|
|
.compactMap(\.description)
|
|
|
|
if descriptions.count == 1 {
|
|
return Text("accessibility.image.alt-text-\(descriptions[0])") + Text(", ")
|
|
} else if descriptions.count > 1 {
|
|
return Text("accessibility.image.alt-text-\(descriptions[0])") + Text(", ") + Text("accessibility.image.alt-text-more.label") + Text(", ")
|
|
} else if viewModel.finalStatus.mediaAttachments.isEmpty == false {
|
|
let differentTypes = Set(viewModel.finalStatus.mediaAttachments.compactMap(\.localizedTypeDescription)).sorted()
|
|
return Text("accessibility.status.contains-media.label-\(ListFormatter.localizedString(byJoining: differentTypes))") + Text(", ")
|
|
} else {
|
|
return Text("")
|
|
}
|
|
}
|
|
|
|
func pollText() -> Text {
|
|
if let poll = viewModel.finalStatus.poll {
|
|
let showPercentage = poll.expired || poll.voted ?? false
|
|
let title: LocalizedStringKey = poll.expired
|
|
? "accessibility.status.poll.finished.label"
|
|
: "accessibility.status.poll.active.label"
|
|
|
|
return poll.options.enumerated().reduce(into: Text(title)) { text, pair in
|
|
let (index, option) = pair
|
|
let selected = poll.ownVotes?.contains(index) ?? false
|
|
let percentage = poll.safeVotersCount > 0 && option.votesCount != nil
|
|
? Int(round(Double(option.votesCount!) / Double(poll.safeVotersCount) * 100))
|
|
: 0
|
|
|
|
text = text +
|
|
Text(selected ? "accessibility.status.poll.selected.label" : "") +
|
|
Text(", ") +
|
|
Text("accessibility.status.poll.option-prefix-\(index + 1)-of-\(poll.options.count)") +
|
|
Text(", ") +
|
|
Text(option.title) +
|
|
Text(showPercentage ? ", \(percentage)%. " : ". ")
|
|
}
|
|
}
|
|
return Text("")
|
|
}
|
|
}
|