mirror of
https://github.com/Dimillian/IceCubesApp.git
synced 2024-11-29 11:41:01 +00:00
StatusRow: Split into proper view struct
This commit is contained in:
parent
b7e7ee0736
commit
a3744525df
15 changed files with 459 additions and 395 deletions
|
@ -214,14 +214,14 @@ public struct ExploreView: View {
|
||||||
Section("explore.section.trending.links") {
|
Section("explore.section.trending.links") {
|
||||||
ForEach(viewModel.trendingLinks
|
ForEach(viewModel.trendingLinks
|
||||||
.prefix(upTo: viewModel.trendingLinks.count > 3 ? 3 : viewModel.trendingLinks.count)) { card in
|
.prefix(upTo: viewModel.trendingLinks.count > 3 ? 3 : viewModel.trendingLinks.count)) { card in
|
||||||
StatusCardView(card: card)
|
StatusRowCardView(card: card)
|
||||||
.listRowBackground(theme.primaryBackgroundColor)
|
.listRowBackground(theme.primaryBackgroundColor)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
}
|
}
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
List {
|
List {
|
||||||
ForEach(viewModel.trendingLinks) { card in
|
ForEach(viewModel.trendingLinks) { card in
|
||||||
StatusCardView(card: card)
|
StatusRowCardView(card: card)
|
||||||
.listRowBackground(theme.primaryBackgroundColor)
|
.listRowBackground(theme.primaryBackgroundColor)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,8 +8,9 @@ import SwiftUI
|
||||||
|
|
||||||
public struct StatusRowView: View {
|
public struct StatusRowView: View {
|
||||||
@Environment(\.redactionReasons) private var reasons
|
@Environment(\.redactionReasons) private var reasons
|
||||||
@EnvironmentObject private var preferences: UserPreferences
|
|
||||||
@EnvironmentObject private var theme: Theme
|
@EnvironmentObject private var theme: Theme
|
||||||
|
|
||||||
@StateObject var viewModel: StatusRowViewModel
|
@StateObject var viewModel: StatusRowViewModel
|
||||||
|
|
||||||
public init(viewModel: StatusRowViewModel) {
|
public init(viewModel: StatusRowViewModel) {
|
||||||
|
@ -31,16 +32,15 @@ public struct StatusRowView: View {
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
let status: AnyStatus = viewModel.status.reblog ?? viewModel.status
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
if !viewModel.isCompact, theme.avatarPosition == .leading {
|
if !viewModel.isCompact, theme.avatarPosition == .leading {
|
||||||
reblogView
|
StatusRowReblogView(viewModel: viewModel)
|
||||||
replyView
|
StatusRowReplyView(viewModel: viewModel)
|
||||||
}
|
}
|
||||||
HStack(alignment: .top, spacing: .statusColumnsSpacing) {
|
HStack(alignment: .top, spacing: .statusColumnsSpacing) {
|
||||||
if !viewModel.isCompact,
|
if !viewModel.isCompact,
|
||||||
theme.avatarPosition == .leading,
|
theme.avatarPosition == .leading {
|
||||||
let status: AnyStatus = viewModel.status.reblog ?? viewModel.status
|
|
||||||
{
|
|
||||||
Button {
|
Button {
|
||||||
viewModel.routerPath.navigate(to: .accountDetailWithAccount(account: status.account))
|
viewModel.routerPath.navigate(to: .accountDetailWithAccount(account: status.account))
|
||||||
} label: {
|
} label: {
|
||||||
|
@ -49,12 +49,26 @@ public struct StatusRowView: View {
|
||||||
}
|
}
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
if !viewModel.isCompact, theme.avatarPosition == .top {
|
if !viewModel.isCompact, theme.avatarPosition == .top {
|
||||||
reblogView
|
StatusRowReblogView(viewModel: viewModel)
|
||||||
replyView
|
StatusRowReplyView(viewModel: viewModel)
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
let status: AnyStatus = viewModel.status.reblog ?? viewModel.status
|
||||||
|
if !viewModel.isCompact {
|
||||||
|
StatusRowHeaderView(status: status, viewModel: viewModel)
|
||||||
|
}
|
||||||
|
StatusRowContentView(status: status, viewModel: viewModel)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
viewModel.navigateToDetail()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: viewModel.isFocused ? .contain : .combine)
|
||||||
|
.accessibilityAction {
|
||||||
|
viewModel.navigateToDetail()
|
||||||
}
|
}
|
||||||
statusView
|
|
||||||
if viewModel.showActions, theme.statusActionsDisplay != .none {
|
if viewModel.showActions, theme.statusActionsDisplay != .none {
|
||||||
StatusActionsView(viewModel: viewModel)
|
StatusRowActionsView(viewModel: viewModel)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
.tint(viewModel.isFocused ? theme.tintColor : .gray)
|
.tint(viewModel.isFocused ? theme.tintColor : .gray)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
|
@ -78,15 +92,14 @@ public struct StatusRowView: View {
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
contextMenu
|
contextMenu
|
||||||
}
|
}
|
||||||
|
|
||||||
.swipeActions(edge: .trailing) {
|
.swipeActions(edge: .trailing) {
|
||||||
if !viewModel.isCompact {
|
if !viewModel.isCompact {
|
||||||
trailingSwipeActions
|
StatusRowSwipeView(viewModel: viewModel, mode: .trailing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.swipeActions(edge: .leading) {
|
.swipeActions(edge: .leading) {
|
||||||
if !viewModel.isCompact {
|
if !viewModel.isCompact {
|
||||||
leadingSwipeActions
|
StatusRowSwipeView(viewModel: viewModel, mode: .leading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listRowBackground(viewModel.highlightRowColor)
|
.listRowBackground(viewModel.highlightRowColor)
|
||||||
|
@ -167,285 +180,6 @@ public struct StatusRowView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var reblogView: some View {
|
|
||||||
if viewModel.status.reblog != nil {
|
|
||||||
HStack(spacing: 2) {
|
|
||||||
Image(systemName: "arrow.left.arrow.right.circle.fill")
|
|
||||||
AvatarView(url: viewModel.status.account.avatar, size: .boost)
|
|
||||||
EmojiTextApp(.init(stringValue: viewModel.status.account.safeDisplayName), emojis: viewModel.status.account.emojis)
|
|
||||||
Text("status.row.was-boosted")
|
|
||||||
}
|
|
||||||
.accessibilityElement()
|
|
||||||
.accessibilityLabel(
|
|
||||||
Text("\(viewModel.status.account.safeDisplayName)")
|
|
||||||
+ Text(" ")
|
|
||||||
+ Text("status.row.was-boosted")
|
|
||||||
)
|
|
||||||
.font(.scaledFootnote)
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
.onTapGesture {
|
|
||||||
viewModel.navigateToAccountDetail(account: viewModel.status.account)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
var replyView: some View {
|
|
||||||
if let accountId = viewModel.status.inReplyToAccountId,
|
|
||||||
let mention = viewModel.status.mentions.first(where: { $0.id == accountId })
|
|
||||||
{
|
|
||||||
HStack(spacing: 2) {
|
|
||||||
Image(systemName: "arrowshape.turn.up.left.fill")
|
|
||||||
Text("status.row.was-reply")
|
|
||||||
Text(mention.username)
|
|
||||||
}
|
|
||||||
.font(.scaledFootnote)
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
.onTapGesture {
|
|
||||||
viewModel.navigateToMention(mention: mention)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var statusView: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
if let status: AnyStatus = viewModel.status.reblog ?? viewModel.status {
|
|
||||||
if !viewModel.isCompact {
|
|
||||||
HStack(alignment: .center) {
|
|
||||||
Button {
|
|
||||||
viewModel.navigateToAccountDetail(account: status.account)
|
|
||||||
} label: {
|
|
||||||
accountView(status: status)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
Spacer()
|
|
||||||
threadIcon
|
|
||||||
contextMenuButton
|
|
||||||
}
|
|
||||||
.accessibilityElement()
|
|
||||||
.accessibilityLabel(Text("\(status.account.displayName)"))
|
|
||||||
}
|
|
||||||
makeStatusContentView(status: status)
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture {
|
|
||||||
viewModel.navigateToDetail()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.accessibilityElement(children: viewModel.isFocused ? .contain : .combine)
|
|
||||||
.accessibilityAction {
|
|
||||||
viewModel.navigateToDetail()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeStatusContentView(status: AnyStatus) -> some View {
|
|
||||||
Group {
|
|
||||||
if !status.spoilerText.asRawText.isEmpty {
|
|
||||||
HStack(alignment: .top) {
|
|
||||||
Text("⚠︎")
|
|
||||||
.font(.system(.subheadline, weight: .bold))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
EmojiTextApp(status.spoilerText, emojis: status.emojis, language: status.language)
|
|
||||||
.font(.system(.subheadline, weight: .bold))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.multilineTextAlignment(.leading)
|
|
||||||
Spacer()
|
|
||||||
Button {
|
|
||||||
withAnimation {
|
|
||||||
viewModel.displaySpoiler.toggle()
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "chevron.down")
|
|
||||||
.rotationEffect(Angle(degrees: viewModel.displaySpoiler ? 0 : 180))
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.accessibility(label: viewModel.displaySpoiler ? Text("status.show-more") : Text("status.show-less"))
|
|
||||||
.accessibilityHidden(true)
|
|
||||||
}
|
|
||||||
.onTapGesture { // make whole row tapable to make up for smaller button size
|
|
||||||
withAnimation {
|
|
||||||
viewModel.displaySpoiler.toggle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !viewModel.displaySpoiler {
|
|
||||||
HStack {
|
|
||||||
EmojiTextApp(status.content, emojis: status.emojis, language: status.language)
|
|
||||||
.font(.scaledBody)
|
|
||||||
.environment(\.openURL, OpenURLAction { url in
|
|
||||||
viewModel.routerPath.handleStatus(status: status, url: url)
|
|
||||||
})
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
|
|
||||||
makeTranslateView(status: status)
|
|
||||||
|
|
||||||
if let poll = status.poll {
|
|
||||||
StatusPollView(poll: poll, status: status)
|
|
||||||
}
|
|
||||||
|
|
||||||
embedStatusView
|
|
||||||
|
|
||||||
makeMediasView(status: status)
|
|
||||||
.accessibilityHidden(!viewModel.isFocused)
|
|
||||||
makeCardView(status: status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func accountView(status: AnyStatus) -> some View {
|
|
||||||
HStack(alignment: .center) {
|
|
||||||
if theme.avatarPosition == .top {
|
|
||||||
AvatarView(url: status.account.avatar, size: .status)
|
|
||||||
}
|
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
|
||||||
EmojiTextApp(.init(stringValue: status.account.safeDisplayName), emojis: status.account.emojis)
|
|
||||||
.font(.scaledSubheadline)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
Group {
|
|
||||||
Text("@\(status.account.acct)") +
|
|
||||||
Text(" ⸱ ") +
|
|
||||||
Text(status.createdAt.relativeFormatted) +
|
|
||||||
Text(" ⸱ ") +
|
|
||||||
Text(Image(systemName: viewModel.status.visibility.iconName))
|
|
||||||
}
|
|
||||||
.font(.scaledFootnote)
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var threadIcon: some View {
|
|
||||||
if viewModel.status.reblog?.inReplyToAccountId != nil || viewModel.status.inReplyToAccountId != nil {
|
|
||||||
Image(systemName: "bubble.left.and.bubble.right")
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fit)
|
|
||||||
.frame(width: 15)
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var contextMenuButton: some View {
|
|
||||||
Menu {
|
|
||||||
contextMenu
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "ellipsis")
|
|
||||||
.frame(width: 20, height: 30)
|
|
||||||
}
|
|
||||||
.menuStyle(.borderlessButton)
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.accessibilityHidden(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func shouldShowTranslateButton(status: AnyStatus) -> Bool {
|
|
||||||
let statusLang = viewModel.getStatusLang()
|
|
||||||
|
|
||||||
if let userLang = preferences.serverPreferences?.postLanguage,
|
|
||||||
preferences.showTranslateButton,
|
|
||||||
!status.content.asRawText.isEmpty,
|
|
||||||
viewModel.translation == nil
|
|
||||||
{
|
|
||||||
return userLang != statusLang
|
|
||||||
} else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func makeTranslateView(status: AnyStatus) -> some View {
|
|
||||||
if let userLang = preferences.serverPreferences?.postLanguage,
|
|
||||||
shouldShowTranslateButton(status: status)
|
|
||||||
{
|
|
||||||
Button {
|
|
||||||
Task {
|
|
||||||
await viewModel.translate(userLang: userLang)
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
if viewModel.isLoadingTranslation {
|
|
||||||
ProgressView()
|
|
||||||
} else {
|
|
||||||
if let statusLanguage = viewModel.getStatusLang(),
|
|
||||||
let languageName = Locale.current.localizedString(forLanguageCode: statusLanguage)
|
|
||||||
{
|
|
||||||
Text("status.action.translate-from-\(languageName)")
|
|
||||||
} else {
|
|
||||||
Text("status.action.translate")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderless)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let translation = viewModel.translation, !viewModel.isLoadingTranslation {
|
|
||||||
GroupBox {
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text(translation.content.asSafeMarkdownAttributedString)
|
|
||||||
.font(.scaledBody)
|
|
||||||
Text("status.action.translated-label-\(translation.provider)")
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func makeMediasView(status: AnyStatus) -> some View {
|
|
||||||
if !status.mediaAttachments.isEmpty {
|
|
||||||
if theme.statusDisplayStyle == .compact {
|
|
||||||
HStack {
|
|
||||||
StatusMediaPreviewView(attachments: status.mediaAttachments,
|
|
||||||
sensitive: status.sensitive,
|
|
||||||
isNotifications: viewModel.isCompact)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
} else {
|
|
||||||
StatusMediaPreviewView(attachments: status.mediaAttachments,
|
|
||||||
sensitive: status.sensitive,
|
|
||||||
isNotifications: viewModel.isCompact)
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func makeCardView(status: AnyStatus) -> some View {
|
|
||||||
if let card = status.card,
|
|
||||||
!viewModel.isEmbedLoading,
|
|
||||||
!viewModel.isCompact,
|
|
||||||
theme.statusDisplayStyle == .large,
|
|
||||||
status.content.statusesURLs.isEmpty,
|
|
||||||
status.mediaAttachments.isEmpty
|
|
||||||
{
|
|
||||||
StatusCardView(card: card)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var embedStatusView: some View {
|
|
||||||
if !reasons.contains(.placeholder) {
|
|
||||||
if !viewModel.isCompact, !viewModel.isEmbedLoading,
|
|
||||||
let embed = viewModel.embeddedStatus
|
|
||||||
{
|
|
||||||
StatusEmbeddedView(status: embed, client: viewModel.client, routerPath: viewModel.routerPath)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
} else if viewModel.isEmbedLoading, !viewModel.isCompact {
|
|
||||||
StatusEmbeddedView(status: .placeholder(), client: viewModel.client, routerPath: viewModel.routerPath)
|
|
||||||
.redacted(reason: .placeholder)
|
|
||||||
.shimmering()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var remoteContentLoadingView: some View {
|
private var remoteContentLoadingView: some View {
|
||||||
ZStack(alignment: .center) {
|
ZStack(alignment: .center) {
|
||||||
VStack {
|
VStack {
|
||||||
|
@ -461,101 +195,4 @@ public struct StatusRowView: View {
|
||||||
.background(Color.black.opacity(0.40))
|
.background(Color.black.opacity(0.40))
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var trailingSwipeActions: some View {
|
|
||||||
if preferences.swipeActionsStatusTrailingRight != StatusAction.none, !viewModel.isRemote {
|
|
||||||
makeSwipeButton(action: preferences.swipeActionsStatusTrailingRight)
|
|
||||||
.tint(preferences.swipeActionsStatusTrailingRight.color(themeTintColor: theme.tintColor, useThemeColor: preferences.swipeActionsUseThemeColor, outside: true))
|
|
||||||
}
|
|
||||||
if preferences.swipeActionsStatusTrailingLeft != StatusAction.none, !viewModel.isRemote {
|
|
||||||
makeSwipeButton(action: preferences.swipeActionsStatusTrailingLeft)
|
|
||||||
.tint(preferences.swipeActionsStatusTrailingLeft.color(themeTintColor: theme.tintColor, useThemeColor: preferences.swipeActionsUseThemeColor, outside: false))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var leadingSwipeActions: some View {
|
|
||||||
if preferences.swipeActionsStatusLeadingLeft != StatusAction.none, !viewModel.isRemote {
|
|
||||||
makeSwipeButton(action: preferences.swipeActionsStatusLeadingLeft)
|
|
||||||
.tint(preferences.swipeActionsStatusLeadingLeft.color(themeTintColor: theme.tintColor, useThemeColor: preferences.swipeActionsUseThemeColor, outside: true))
|
|
||||||
}
|
|
||||||
if preferences.swipeActionsStatusLeadingRight != StatusAction.none, !viewModel.isRemote {
|
|
||||||
makeSwipeButton(action: preferences.swipeActionsStatusLeadingRight)
|
|
||||||
.tint(preferences.swipeActionsStatusLeadingRight.color(themeTintColor: theme.tintColor, useThemeColor: preferences.swipeActionsUseThemeColor, outside: false))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func makeSwipeButton(action: StatusAction) -> some View {
|
|
||||||
switch action {
|
|
||||||
case .reply:
|
|
||||||
makeSwipeButtonForRouterPath(action: action, destination: .replyToStatusEditor(status: viewModel.status))
|
|
||||||
case .quote:
|
|
||||||
makeSwipeButtonForRouterPath(action: action, destination: .quoteStatusEditor(status: viewModel.status))
|
|
||||||
case .favorite:
|
|
||||||
makeSwipeButtonForTask(action: action) {
|
|
||||||
if viewModel.isFavorited {
|
|
||||||
await viewModel.unFavorite()
|
|
||||||
} else {
|
|
||||||
await viewModel.favorite()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case .boost:
|
|
||||||
makeSwipeButtonForTask(action: action) {
|
|
||||||
if viewModel.isReblogged {
|
|
||||||
await viewModel.unReblog()
|
|
||||||
} else {
|
|
||||||
await viewModel.reblog()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case .bookmark:
|
|
||||||
makeSwipeButtonForTask(action: action) {
|
|
||||||
if viewModel.isBookmarked {
|
|
||||||
await viewModel.unbookmark()
|
|
||||||
} else {
|
|
||||||
await
|
|
||||||
viewModel.bookmark()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case .none:
|
|
||||||
EmptyView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func makeSwipeButtonForRouterPath(action: StatusAction, destination: SheetDestinations) -> some View {
|
|
||||||
Button {
|
|
||||||
HapticManager.shared.fireHaptic(of: .notification(.success))
|
|
||||||
viewModel.routerPath.presentedSheet = destination
|
|
||||||
} label: {
|
|
||||||
makeSwipeLabel(action: action, style: preferences.swipeActionsIconStyle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func makeSwipeButtonForTask(action: StatusAction, task: @escaping () async -> Void) -> some View {
|
|
||||||
Button {
|
|
||||||
Task {
|
|
||||||
HapticManager.shared.fireHaptic(of: .notification(.success))
|
|
||||||
await task()
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
makeSwipeLabel(action: action, style: preferences.swipeActionsIconStyle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func makeSwipeLabel(action: StatusAction, style: UserPreferences.SwipeActionsIconStyle) -> some View {
|
|
||||||
switch (style) {
|
|
||||||
case .iconOnly:
|
|
||||||
Label(action.displayName(isReblogged: viewModel.isReblogged, isFavorited: viewModel.isFavorited, isBookmarked: viewModel.isBookmarked), systemImage: action.iconName(isReblogged: viewModel.isReblogged, isFavorited: viewModel.isFavorited, isBookmarked: viewModel.isBookmarked))
|
|
||||||
.labelStyle(.iconOnly)
|
|
||||||
.environment(\.symbolVariants, .none)
|
|
||||||
case .iconWithText:
|
|
||||||
Label(action.displayName(isReblogged: viewModel.isReblogged, isFavorited: viewModel.isFavorited, isBookmarked: viewModel.isBookmarked), systemImage: action.iconName(isReblogged: viewModel.isReblogged, isFavorited: viewModel.isFavorited, isBookmarked: viewModel.isBookmarked))
|
|
||||||
.labelStyle(.titleAndIcon)
|
|
||||||
.environment(\.symbolVariants, .none)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@ import Models
|
||||||
import Network
|
import Network
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct StatusActionsView: View {
|
struct StatusRowActionsView: View {
|
||||||
@EnvironmentObject private var theme: Theme
|
@EnvironmentObject private var theme: Theme
|
||||||
@ObservedObject var viewModel: StatusRowViewModel
|
@ObservedObject var viewModel: StatusRowViewModel
|
||||||
|
|
|
@ -4,7 +4,7 @@ import NukeUI
|
||||||
import Shimmer
|
import Shimmer
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
public struct StatusCardView: View {
|
public struct StatusRowCardView: View {
|
||||||
@EnvironmentObject private var theme: Theme
|
@EnvironmentObject private var theme: Theme
|
||||||
@Environment(\.openURL) private var openURL
|
@Environment(\.openURL) private var openURL
|
||||||
let card: Card
|
let card: Card
|
|
@ -0,0 +1,59 @@
|
||||||
|
import SwiftUI
|
||||||
|
import DesignSystem
|
||||||
|
import Models
|
||||||
|
import Env
|
||||||
|
|
||||||
|
struct StatusRowContentView: View {
|
||||||
|
@Environment(\.redactionReasons) private var reasons
|
||||||
|
@EnvironmentObject private var theme: Theme
|
||||||
|
|
||||||
|
let status: AnyStatus
|
||||||
|
@ObservedObject var viewModel: StatusRowViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if !status.spoilerText.asRawText.isEmpty {
|
||||||
|
StatusRowSpoilerView(status: status, displaySpoiler: $viewModel.displaySpoiler)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !viewModel.displaySpoiler {
|
||||||
|
StatusRowTextView(status: status, viewModel: viewModel)
|
||||||
|
StatusRowTranslateView(status: status, viewModel: viewModel)
|
||||||
|
if let poll = status.poll {
|
||||||
|
StatusPollView(poll: poll, status: status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reasons.contains(.placeholder),
|
||||||
|
!viewModel.isCompact,
|
||||||
|
(viewModel.isEmbedLoading || viewModel.embeddedStatus != nil) {
|
||||||
|
StatusEmbeddedView(status: viewModel.embeddedStatus ?? Status.placeholder(),
|
||||||
|
client: viewModel.client,
|
||||||
|
routerPath: viewModel.routerPath)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.redacted(reason: viewModel.isEmbedLoading ? .placeholder : [])
|
||||||
|
.shimmering(active: viewModel.isEmbedLoading)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !status.mediaAttachments.isEmpty {
|
||||||
|
HStack {
|
||||||
|
StatusRowMediaPreviewView(attachments: status.mediaAttachments,
|
||||||
|
sensitive: status.sensitive,
|
||||||
|
isNotifications: viewModel.isCompact)
|
||||||
|
if theme.statusDisplayStyle == .compact {
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let card = status.card,
|
||||||
|
!viewModel.isEmbedLoading,
|
||||||
|
!viewModel.isCompact,
|
||||||
|
theme.statusDisplayStyle == .large,
|
||||||
|
status.content.statusesURLs.isEmpty,
|
||||||
|
status.mediaAttachments.isEmpty
|
||||||
|
{
|
||||||
|
StatusRowCardView(card: card)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,73 @@
|
||||||
|
import SwiftUI
|
||||||
|
import DesignSystem
|
||||||
|
import Models
|
||||||
|
|
||||||
|
struct StatusRowHeaderView: View {
|
||||||
|
@EnvironmentObject private var theme: Theme
|
||||||
|
|
||||||
|
let status: AnyStatus
|
||||||
|
let viewModel: StatusRowViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .center) {
|
||||||
|
Button {
|
||||||
|
viewModel.navigateToAccountDetail(account: status.account)
|
||||||
|
} label: {
|
||||||
|
accountView(status: status)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
Spacer()
|
||||||
|
threadIcon
|
||||||
|
contextMenuButton
|
||||||
|
}
|
||||||
|
.accessibilityElement()
|
||||||
|
.accessibilityLabel(Text("\(status.account.displayName)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func accountView(status: AnyStatus) -> some View {
|
||||||
|
HStack(alignment: .center) {
|
||||||
|
if theme.avatarPosition == .top {
|
||||||
|
AvatarView(url: status.account.avatar, size: .status)
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
EmojiTextApp(.init(stringValue: status.account.safeDisplayName), emojis: status.account.emojis)
|
||||||
|
.font(.scaledSubheadline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
Group {
|
||||||
|
Text("@\(status.account.acct)") +
|
||||||
|
Text(" ⸱ ") +
|
||||||
|
Text(status.createdAt.relativeFormatted) +
|
||||||
|
Text(" ⸱ ") +
|
||||||
|
Text(Image(systemName: viewModel.status.visibility.iconName))
|
||||||
|
}
|
||||||
|
.font(.scaledFootnote)
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var threadIcon: some View {
|
||||||
|
if viewModel.status.reblog?.inReplyToAccountId != nil || viewModel.status.inReplyToAccountId != nil {
|
||||||
|
Image(systemName: "bubble.left.and.bubble.right")
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(width: 15)
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var contextMenuButton: some View {
|
||||||
|
Menu {
|
||||||
|
StatusRowContextMenu(viewModel: viewModel)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis")
|
||||||
|
.frame(width: 20, height: 30)
|
||||||
|
}
|
||||||
|
.menuStyle(.borderlessButton)
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,7 +5,7 @@ import Nuke
|
||||||
import NukeUI
|
import NukeUI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
public struct StatusMediaPreviewView: View {
|
public struct StatusRowMediaPreviewView: View {
|
||||||
@Environment(\.openURL) private var openURL
|
@Environment(\.openURL) private var openURL
|
||||||
@Environment(\.isSecondaryColumn) private var isSecondaryColumn
|
@Environment(\.isSecondaryColumn) private var isSecondaryColumn
|
||||||
|
|
|
@ -0,0 +1,30 @@
|
||||||
|
import SwiftUI
|
||||||
|
import DesignSystem
|
||||||
|
|
||||||
|
struct StatusRowReblogView: View {
|
||||||
|
let viewModel: StatusRowViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if viewModel.status.reblog != nil {
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
Image(systemName: "arrow.left.arrow.right.circle.fill")
|
||||||
|
AvatarView(url: viewModel.status.account.avatar, size: .boost)
|
||||||
|
EmojiTextApp(.init(stringValue: viewModel.status.account.safeDisplayName), emojis: viewModel.status.account.emojis)
|
||||||
|
Text("status.row.was-boosted")
|
||||||
|
}
|
||||||
|
.accessibilityElement()
|
||||||
|
.accessibilityLabel(
|
||||||
|
Text("\(viewModel.status.account.safeDisplayName)")
|
||||||
|
+ Text(" ")
|
||||||
|
+ Text("status.row.was-boosted")
|
||||||
|
)
|
||||||
|
.font(.scaledFootnote)
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.onTapGesture {
|
||||||
|
viewModel.navigateToAccountDetail(account: viewModel.status.account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,24 @@
|
||||||
|
import SwiftUI
|
||||||
|
import DesignSystem
|
||||||
|
|
||||||
|
struct StatusRowReplyView: View {
|
||||||
|
let viewModel: StatusRowViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let accountId = viewModel.status.inReplyToAccountId,
|
||||||
|
let mention = viewModel.status.mentions.first(where: { $0.id == accountId })
|
||||||
|
{
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
Image(systemName: "arrowshape.turn.up.left.fill")
|
||||||
|
Text("status.row.was-reply")
|
||||||
|
Text(mention.username)
|
||||||
|
}
|
||||||
|
.font(.scaledFootnote)
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.onTapGesture {
|
||||||
|
viewModel.navigateToMention(mention: mention)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
import SwiftUI
|
||||||
|
import DesignSystem
|
||||||
|
import Models
|
||||||
|
|
||||||
|
struct StatusRowSpoilerView: View {
|
||||||
|
let status: AnyStatus
|
||||||
|
@Binding var displaySpoiler: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
Text("⚠︎")
|
||||||
|
.font(.system(.subheadline, weight: .bold))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
EmojiTextApp(status.spoilerText, emojis: status.emojis, language: status.language)
|
||||||
|
.font(.system(.subheadline, weight: .bold))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
withAnimation {
|
||||||
|
displaySpoiler.toggle()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chevron.down")
|
||||||
|
.rotationEffect(Angle(degrees: displaySpoiler ? 0 : 180))
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.accessibility(label: displaySpoiler ? Text("status.show-more") : Text("status.show-less"))
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
.onTapGesture { // make whole row tapable to make up for smaller button size
|
||||||
|
withAnimation {
|
||||||
|
displaySpoiler.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,122 @@
|
||||||
|
import SwiftUI
|
||||||
|
import Env
|
||||||
|
import Models
|
||||||
|
import DesignSystem
|
||||||
|
|
||||||
|
struct StatusRowSwipeView: View {
|
||||||
|
@EnvironmentObject private var theme: Theme
|
||||||
|
@EnvironmentObject private var preferences: UserPreferences
|
||||||
|
|
||||||
|
enum Mode {
|
||||||
|
case leading, trailing
|
||||||
|
}
|
||||||
|
|
||||||
|
let viewModel: StatusRowViewModel
|
||||||
|
let mode: Mode
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch mode {
|
||||||
|
case .leading:
|
||||||
|
leadingSwipeActions
|
||||||
|
case .trailing:
|
||||||
|
trailingSwipeActions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var trailingSwipeActions: some View {
|
||||||
|
if preferences.swipeActionsStatusTrailingRight != StatusAction.none, !viewModel.isRemote {
|
||||||
|
makeSwipeButton(action: preferences.swipeActionsStatusTrailingRight)
|
||||||
|
.tint(preferences.swipeActionsStatusTrailingRight.color(themeTintColor: theme.tintColor, useThemeColor: preferences.swipeActionsUseThemeColor, outside: true))
|
||||||
|
}
|
||||||
|
if preferences.swipeActionsStatusTrailingLeft != StatusAction.none, !viewModel.isRemote {
|
||||||
|
makeSwipeButton(action: preferences.swipeActionsStatusTrailingLeft)
|
||||||
|
.tint(preferences.swipeActionsStatusTrailingLeft.color(themeTintColor: theme.tintColor, useThemeColor: preferences.swipeActionsUseThemeColor, outside: false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var leadingSwipeActions: some View {
|
||||||
|
if preferences.swipeActionsStatusLeadingLeft != StatusAction.none, !viewModel.isRemote {
|
||||||
|
makeSwipeButton(action: preferences.swipeActionsStatusLeadingLeft)
|
||||||
|
.tint(preferences.swipeActionsStatusLeadingLeft.color(themeTintColor: theme.tintColor, useThemeColor: preferences.swipeActionsUseThemeColor, outside: true))
|
||||||
|
}
|
||||||
|
if preferences.swipeActionsStatusLeadingRight != StatusAction.none, !viewModel.isRemote {
|
||||||
|
makeSwipeButton(action: preferences.swipeActionsStatusLeadingRight)
|
||||||
|
.tint(preferences.swipeActionsStatusLeadingRight.color(themeTintColor: theme.tintColor, useThemeColor: preferences.swipeActionsUseThemeColor, outside: false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func makeSwipeButton(action: StatusAction) -> some View {
|
||||||
|
switch action {
|
||||||
|
case .reply:
|
||||||
|
makeSwipeButtonForRouterPath(action: action, destination: .replyToStatusEditor(status: viewModel.status))
|
||||||
|
case .quote:
|
||||||
|
makeSwipeButtonForRouterPath(action: action, destination: .quoteStatusEditor(status: viewModel.status))
|
||||||
|
case .favorite:
|
||||||
|
makeSwipeButtonForTask(action: action) {
|
||||||
|
if viewModel.isFavorited {
|
||||||
|
await viewModel.unFavorite()
|
||||||
|
} else {
|
||||||
|
await viewModel.favorite()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .boost:
|
||||||
|
makeSwipeButtonForTask(action: action) {
|
||||||
|
if viewModel.isReblogged {
|
||||||
|
await viewModel.unReblog()
|
||||||
|
} else {
|
||||||
|
await viewModel.reblog()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .bookmark:
|
||||||
|
makeSwipeButtonForTask(action: action) {
|
||||||
|
if viewModel.isBookmarked {
|
||||||
|
await viewModel.unbookmark()
|
||||||
|
} else {
|
||||||
|
await
|
||||||
|
viewModel.bookmark()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .none:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func makeSwipeButtonForRouterPath(action: StatusAction, destination: SheetDestinations) -> some View {
|
||||||
|
Button {
|
||||||
|
HapticManager.shared.fireHaptic(of: .notification(.success))
|
||||||
|
viewModel.routerPath.presentedSheet = destination
|
||||||
|
} label: {
|
||||||
|
makeSwipeLabel(action: action, style: preferences.swipeActionsIconStyle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func makeSwipeButtonForTask(action: StatusAction, task: @escaping () async -> Void) -> some View {
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
HapticManager.shared.fireHaptic(of: .notification(.success))
|
||||||
|
await task()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
makeSwipeLabel(action: action, style: preferences.swipeActionsIconStyle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func makeSwipeLabel(action: StatusAction, style: UserPreferences.SwipeActionsIconStyle) -> some View {
|
||||||
|
switch (style) {
|
||||||
|
case .iconOnly:
|
||||||
|
Label(action.displayName(isReblogged: viewModel.isReblogged, isFavorited: viewModel.isFavorited, isBookmarked: viewModel.isBookmarked), systemImage: action.iconName(isReblogged: viewModel.isReblogged, isFavorited: viewModel.isFavorited, isBookmarked: viewModel.isBookmarked))
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
|
.environment(\.symbolVariants, .none)
|
||||||
|
case .iconWithText:
|
||||||
|
Label(action.displayName(isReblogged: viewModel.isReblogged, isFavorited: viewModel.isFavorited, isBookmarked: viewModel.isBookmarked), systemImage: action.iconName(isReblogged: viewModel.isReblogged, isFavorited: viewModel.isFavorited, isBookmarked: viewModel.isBookmarked))
|
||||||
|
.labelStyle(.titleAndIcon)
|
||||||
|
.environment(\.symbolVariants, .none)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
import SwiftUI
|
||||||
|
import DesignSystem
|
||||||
|
import Models
|
||||||
|
|
||||||
|
struct StatusRowTextView: View {
|
||||||
|
let status: AnyStatus
|
||||||
|
let viewModel: StatusRowViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
EmojiTextApp(status.content, emojis: status.emojis, language: status.language)
|
||||||
|
.font(.scaledBody)
|
||||||
|
.environment(\.openURL, OpenURLAction { url in
|
||||||
|
viewModel.routerPath.handleStatus(status: status, url: url)
|
||||||
|
})
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,63 @@
|
||||||
|
import SwiftUI
|
||||||
|
import Models
|
||||||
|
import DesignSystem
|
||||||
|
import Env
|
||||||
|
|
||||||
|
struct StatusRowTranslateView: View {
|
||||||
|
@EnvironmentObject private var preferences: UserPreferences
|
||||||
|
|
||||||
|
let status: AnyStatus
|
||||||
|
@ObservedObject var viewModel: StatusRowViewModel
|
||||||
|
|
||||||
|
private var shouldShowTranslateButton: Bool {
|
||||||
|
let statusLang = viewModel.getStatusLang()
|
||||||
|
|
||||||
|
if let userLang = preferences.serverPreferences?.postLanguage,
|
||||||
|
preferences.showTranslateButton,
|
||||||
|
!status.content.asRawText.isEmpty,
|
||||||
|
viewModel.translation == nil
|
||||||
|
{
|
||||||
|
return userLang != statusLang
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let userLang = preferences.serverPreferences?.postLanguage,
|
||||||
|
shouldShowTranslateButton
|
||||||
|
{
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
await viewModel.translate(userLang: userLang)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
if viewModel.isLoadingTranslation {
|
||||||
|
ProgressView()
|
||||||
|
} else {
|
||||||
|
if let statusLanguage = viewModel.getStatusLang(),
|
||||||
|
let languageName = Locale.current.localizedString(forLanguageCode: statusLanguage)
|
||||||
|
{
|
||||||
|
Text("status.action.translate-from-\(languageName)")
|
||||||
|
} else {
|
||||||
|
Text("status.action.translate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let translation = viewModel.translation, !viewModel.isLoadingTranslation {
|
||||||
|
GroupBox {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(translation.content.asSafeMarkdownAttributedString)
|
||||||
|
.font(.scaledBody)
|
||||||
|
Text("status.action.translated-label-\(translation.provider)")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue