mirror of
https://github.com/Dimillian/IceCubesApp.git
synced 2024-05-19 08:48:16 +00:00
421 lines
15 KiB
Swift
421 lines
15 KiB
Swift
import DesignSystem
|
|
import EmojiText
|
|
import Env
|
|
import Models
|
|
import NukeUI
|
|
import Shimmer
|
|
import SwiftUI
|
|
|
|
@MainActor
|
|
struct AccountDetailHeaderView: View {
|
|
enum Constants {
|
|
static let headerHeight: CGFloat = 200
|
|
}
|
|
|
|
@Environment(\.openWindow) private var openWindow
|
|
@Environment(Theme.self) private var theme
|
|
@Environment(QuickLook.self) private var quickLook
|
|
@Environment(RouterPath.self) private var routerPath
|
|
@Environment(CurrentAccount.self) private var currentAccount
|
|
@Environment(\.redactionReasons) private var reasons
|
|
@Environment(\.isSupporter) private var isSupporter: Bool
|
|
|
|
var viewModel: AccountDetailViewModel
|
|
let account: Account
|
|
let scrollViewProxy: ScrollViewProxy?
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading) {
|
|
ZStack(alignment: .bottomTrailing) {
|
|
Rectangle()
|
|
.frame(height: Constants.headerHeight)
|
|
.overlay {
|
|
headerImageView
|
|
}
|
|
if viewModel.relationship?.followedBy == true {
|
|
Text("account.relation.follows-you")
|
|
.font(.scaledFootnote)
|
|
.fontWeight(.semibold)
|
|
.padding(4)
|
|
.background(.ultraThinMaterial)
|
|
.cornerRadius(4)
|
|
.padding(8)
|
|
}
|
|
}
|
|
accountInfoView
|
|
}
|
|
}
|
|
|
|
private var headerImageView: some View {
|
|
ZStack(alignment: .bottomTrailing) {
|
|
if reasons.contains(.placeholder) {
|
|
Rectangle()
|
|
.foregroundColor(theme.secondaryBackgroundColor)
|
|
.frame(height: Constants.headerHeight)
|
|
.accessibilityHidden(true)
|
|
} else {
|
|
LazyImage(url: account.header) { state in
|
|
if let image = state.image {
|
|
image
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fill)
|
|
.overlay(account.haveHeader ? .black.opacity(0.50) : .clear)
|
|
.frame(height: Constants.headerHeight)
|
|
.clipped()
|
|
} else if state.isLoading {
|
|
theme.secondaryBackgroundColor
|
|
.frame(height: Constants.headerHeight)
|
|
.shimmering()
|
|
} else {
|
|
theme.secondaryBackgroundColor
|
|
.frame(height: Constants.headerHeight)
|
|
}
|
|
}
|
|
.frame(height: Constants.headerHeight)
|
|
}
|
|
}
|
|
#if !os(visionOS)
|
|
.background(theme.secondaryBackgroundColor)
|
|
#endif
|
|
.frame(height: Constants.headerHeight)
|
|
.onTapGesture {
|
|
guard account.haveHeader else {
|
|
return
|
|
}
|
|
let attachement = MediaAttachment.imageWith(url: account.header)
|
|
#if targetEnvironment(macCatalyst)
|
|
openWindow(value: WindowDestinationMedia.mediaViewer(
|
|
attachments: [attachement],
|
|
selectedAttachment: attachement
|
|
))
|
|
#else
|
|
quickLook.prepareFor(selectedMediaAttachment: attachement, mediaAttachments: [attachement])
|
|
#endif
|
|
}
|
|
.accessibilityElement(children: .combine)
|
|
.accessibilityAddTraits([.isImage, .isButton])
|
|
.accessibilityLabel("accessibility.tabs.profile.header-image.label")
|
|
.accessibilityHint("accessibility.tabs.profile.header-image.hint")
|
|
.accessibilityHidden(account.haveHeader == false)
|
|
}
|
|
|
|
private var accountAvatarView: some View {
|
|
HStack {
|
|
ZStack(alignment: .topTrailing) {
|
|
AvatarView(account.avatar, config: .account)
|
|
.accessibilityLabel("accessibility.tabs.profile.user-avatar.label")
|
|
if viewModel.isCurrentUser, isSupporter {
|
|
Image(systemName: "checkmark.seal.fill")
|
|
.resizable()
|
|
.frame(width: 25, height: 25)
|
|
.foregroundColor(theme.tintColor)
|
|
.offset(x: theme.avatarShape == .circle ? 0 : 10,
|
|
y: theme.avatarShape == .circle ? 0 : -10)
|
|
.accessibilityRemoveTraits(.isSelected)
|
|
.accessibilityLabel("accessibility.tabs.profile.user-avatar.supporter.label")
|
|
}
|
|
}
|
|
.onTapGesture {
|
|
guard account.haveAvatar else {
|
|
return
|
|
}
|
|
let attachement = MediaAttachment.imageWith(url: account.avatar)
|
|
#if targetEnvironment(macCatalyst)
|
|
openWindow(value: WindowDestinationMedia.mediaViewer(attachments: [attachement],
|
|
selectedAttachment: attachement))
|
|
#else
|
|
quickLook.prepareFor(selectedMediaAttachment: attachement, mediaAttachments: [attachement])
|
|
#endif
|
|
}
|
|
.accessibilityElement(children: .combine)
|
|
.accessibilityAddTraits([.isImage, .isButton])
|
|
.accessibilityHint("accessibility.tabs.profile.user-avatar.hint")
|
|
.accessibilityHidden(account.haveAvatar == false)
|
|
|
|
Spacer()
|
|
Group {
|
|
Button {
|
|
withAnimation {
|
|
scrollViewProxy?.scrollTo("status", anchor: .top)
|
|
}
|
|
} label: {
|
|
makeCustomInfoLabel(title: "account.posts", count: account.statusesCount ?? 0)
|
|
}
|
|
.accessibilityHint("accessibility.tabs.profile.post-count.hint")
|
|
.buttonStyle(.borderless)
|
|
|
|
Button {
|
|
routerPath.navigate(to: .following(id: account.id))
|
|
} label: {
|
|
makeCustomInfoLabel(title: "account.following", count: account.followingCount ?? 0)
|
|
}
|
|
.accessibilityHint("accessibility.tabs.profile.following-count.hint")
|
|
.buttonStyle(.borderless)
|
|
|
|
Button {
|
|
routerPath.navigate(to: .followers(id: account.id))
|
|
} label: {
|
|
makeCustomInfoLabel(
|
|
title: "account.followers",
|
|
count: account.followersCount ?? 0,
|
|
needsBadge: currentAccount.account?.id == account.id && !currentAccount.followRequests.isEmpty
|
|
)
|
|
}
|
|
.accessibilityHint("accessibility.tabs.profile.follower-count.hint")
|
|
.buttonStyle(.borderless)
|
|
|
|
}.offset(y: 20)
|
|
}
|
|
}
|
|
|
|
private var accountInfoView: some View {
|
|
Group {
|
|
accountAvatarView
|
|
HStack(alignment: .firstTextBaseline) {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
HStack(alignment: .center, spacing: 2) {
|
|
EmojiTextApp(.init(stringValue: account.safeDisplayName), emojis: account.emojis)
|
|
.font(.scaledHeadline)
|
|
.foregroundColor(theme.labelColor)
|
|
.emojiSize(Font.scaledHeadlineFont.emojiSize)
|
|
.emojiBaselineOffset(Font.scaledHeadlineFont.emojiBaselineOffset)
|
|
.accessibilityAddTraits(.isHeader)
|
|
|
|
// The views here are wrapped in ZStacks as a Text(Image) does not provide an `accessibilityLabel`.
|
|
if account.bot {
|
|
ZStack {
|
|
Text(Image(systemName: "poweroutlet.type.b.fill"))
|
|
.font(.footnote)
|
|
}.accessibilityLabel("accessibility.tabs.profile.user.account-bot.label")
|
|
}
|
|
if account.locked {
|
|
ZStack {
|
|
Text(Image(systemName: "lock.fill"))
|
|
.font(.footnote)
|
|
}.accessibilityLabel("accessibility.tabs.profile.user.account-private.label")
|
|
}
|
|
if viewModel.relationship?.blocking == true {
|
|
ZStack {
|
|
Text(Image(systemName: "person.crop.circle.badge.xmark.fill"))
|
|
.font(.footnote)
|
|
}.accessibilityLabel("accessibility.tabs.profile.user.account-blocked.label")
|
|
}
|
|
if viewModel.relationship?.muting == true {
|
|
ZStack {
|
|
Text(Image(systemName: "speaker.slash.fill"))
|
|
.font(.footnote)
|
|
}.accessibilityLabel("accessibility.tabs.profile.user.account-muted.label")
|
|
}
|
|
}
|
|
Text("@\(account.acct)")
|
|
.font(.scaledCallout)
|
|
.foregroundStyle(.secondary)
|
|
.textSelection(.enabled)
|
|
.accessibilityRespondsToUserInteraction(false)
|
|
joinedAtView
|
|
}
|
|
.accessibilityElement(children: .contain)
|
|
.accessibilitySortPriority(1)
|
|
|
|
Spacer()
|
|
if let relationship = viewModel.relationship, !viewModel.isCurrentUser {
|
|
HStack {
|
|
FollowButton(viewModel: .init(accountId: account.id,
|
|
relationship: relationship,
|
|
shouldDisplayNotify: true,
|
|
relationshipUpdated: { relationship in
|
|
viewModel.relationship = relationship
|
|
}))
|
|
}
|
|
}
|
|
}
|
|
|
|
if let note = viewModel.relationship?.note, !note.isEmpty,
|
|
!viewModel.isCurrentUser
|
|
{
|
|
makeNoteView(note)
|
|
}
|
|
|
|
EmojiTextApp(account.note, emojis: account.emojis)
|
|
.font(.scaledBody)
|
|
.foregroundColor(theme.labelColor)
|
|
.emojiSize(Font.scaledBodyFont.emojiSize)
|
|
.emojiBaselineOffset(Font.scaledBodyFont.emojiBaselineOffset)
|
|
.padding(.top, 8)
|
|
.textSelection(.enabled)
|
|
.environment(\.openURL, OpenURLAction { url in
|
|
routerPath.handle(url: url)
|
|
})
|
|
.accessibilityRespondsToUserInteraction(false)
|
|
|
|
if let translation = viewModel.translation, !viewModel.isLoadingTranslation {
|
|
GroupBox {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(translation.content.asSafeMarkdownAttributedString)
|
|
.font(.scaledBody)
|
|
Text(getLocalizedStringLabel(langCode: translation.detectedSourceLanguage, provider: translation.provider))
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
fieldsView
|
|
}
|
|
.padding(.horizontal, .layoutPadding)
|
|
.offset(y: -40)
|
|
}
|
|
|
|
private func getLocalizedStringLabel(langCode: String, provider: String) -> String {
|
|
if let localizedLanguage = Locale.current.localizedString(forLanguageCode: langCode) {
|
|
let format = NSLocalizedString("status.action.translated-label-from-%@-%@", comment: "")
|
|
return String.localizedStringWithFormat(format, localizedLanguage, provider)
|
|
} else {
|
|
return "status.action.translated-label-\(provider)"
|
|
}
|
|
}
|
|
|
|
private func makeCustomInfoLabel(title: LocalizedStringKey, count: Int, needsBadge: Bool = false) -> some View {
|
|
VStack {
|
|
Text(count, format: .number.notation(.compactName))
|
|
.font(.scaledHeadline)
|
|
.foregroundColor(theme.tintColor)
|
|
.overlay(alignment: .trailing) {
|
|
if needsBadge {
|
|
Circle()
|
|
.fill(Color.red)
|
|
.frame(width: 9, height: 9)
|
|
.offset(x: 12)
|
|
}
|
|
}
|
|
Text(title)
|
|
.font(.scaledFootnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel(title)
|
|
.accessibilityValue("\(count)")
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var joinedAtView: some View {
|
|
if let joinedAt = viewModel.account?.createdAt.asDate {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "calendar")
|
|
.accessibilityHidden(true)
|
|
Text("account.joined")
|
|
Text(joinedAt, style: .date)
|
|
}
|
|
.foregroundStyle(.secondary)
|
|
.font(.footnote)
|
|
.padding(.top, 6)
|
|
.accessibilityElement(children: .combine)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func makeNoteView(_ note: String) -> some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("account.relation.note.label")
|
|
.foregroundStyle(.secondary)
|
|
Text(note)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(8)
|
|
#if !os(visionOS)
|
|
.background(theme.secondaryBackgroundColor)
|
|
#endif
|
|
.cornerRadius(4)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.stroke(.gray.opacity(0.35), lineWidth: 1)
|
|
)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var fieldsView: some View {
|
|
if !viewModel.fields.isEmpty {
|
|
VStack(alignment: .leading) {
|
|
ForEach(viewModel.fields) { field in
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
EmojiTextApp(.init(stringValue: field.name), emojis: viewModel.account?.emojis ?? [])
|
|
.emojiSize(Font.scaledHeadlineFont.emojiSize)
|
|
.emojiBaselineOffset(Font.scaledHeadlineFont.emojiBaselineOffset)
|
|
.font(.scaledHeadline)
|
|
HStack {
|
|
if field.verifiedAt != nil {
|
|
Image(systemName: "checkmark.seal")
|
|
.foregroundColor(Color.green.opacity(0.80))
|
|
.accessibilityHidden(true)
|
|
}
|
|
EmojiTextApp(field.value, emojis: viewModel.account?.emojis ?? [])
|
|
.emojiSize(Font.scaledBodyFont.emojiSize)
|
|
.emojiBaselineOffset(Font.scaledBodyFont.emojiBaselineOffset)
|
|
.foregroundColor(theme.tintColor)
|
|
.environment(\.openURL, OpenURLAction { url in
|
|
routerPath.handle(url: url)
|
|
})
|
|
.accessibilityValue(field.verifiedAt != nil ? "accessibility.tabs.profile.fields.verified.label" : "")
|
|
}
|
|
.font(.scaledBody)
|
|
if viewModel.fields.last != field {
|
|
Divider()
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
Spacer()
|
|
}
|
|
.accessibilityElement(children: .combine)
|
|
.modifier(ConditionalUserDefinedFieldAccessibilityActionModifier(field: field, routerPath: routerPath))
|
|
}
|
|
}
|
|
.padding(8)
|
|
.accessibilityElement(children: .contain)
|
|
.accessibilityLabel("accessibility.tabs.profile.fields.container.label")
|
|
#if os(visionOS)
|
|
.background(Material.thick)
|
|
#else
|
|
.background(theme.secondaryBackgroundColor)
|
|
#endif
|
|
.cornerRadius(4)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.stroke(.gray.opacity(0.35), lineWidth: 1)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A ``ViewModifier`` that creates a attaches an accessibility action if the field value is a valid link
|
|
private struct ConditionalUserDefinedFieldAccessibilityActionModifier: ViewModifier {
|
|
let field: Account.Field
|
|
let routerPath: RouterPath
|
|
|
|
func body(content: Content) -> some View {
|
|
if let url = URL(string: field.value.asRawText), UIApplication.shared.canOpenURL(url) {
|
|
content
|
|
.accessibilityAction {
|
|
let _ = routerPath.handle(url: url)
|
|
}
|
|
// SwiftUI will automatically decorate this element with the link trait, so we remove the button trait manually.
|
|
// March 18th, 2023: The button trait is still re-applied…
|
|
.accessibilityRemoveTraits(.isButton)
|
|
.accessibilityInputLabels([field.name])
|
|
} else {
|
|
content
|
|
// This element is not interactive; setting this property removes its button trait
|
|
.accessibilityRespondsToUserInteraction(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct AccountDetailHeaderView_Previews: PreviewProvider {
|
|
static var previews: some View {
|
|
AccountDetailHeaderView(viewModel: .init(account: .placeholder()),
|
|
account: .placeholder(),
|
|
scrollViewProxy: nil)
|
|
}
|
|
}
|