IceCubesApp/Packages/Account/Sources/Account/AccountDetailView.swift
David Davies-Payne 11167c35c3
Also sort tags and lists in profile view (#764)
* Add CurrentAccount.sortedLists and .sortedTags

Sorts alphabetically, ascending, lowercased on title and name respectively.

* TimelineTab uses CurrentAccount.sortedLists and .sortedTags

* Account detail sorts tags and lists

Alphabetically, ascending, lowercased via CurrentAccount.sortedTags, .sortedLists
2023-02-10 06:38:18 +01:00

562 lines
20 KiB
Swift

import DesignSystem
import EmojiText
import Env
import Models
import Network
import Shimmer
import Status
import SwiftUI
public struct AccountDetailView: View {
@Environment(\.redactionReasons) private var reasons
@EnvironmentObject private var watcher: StreamWatcher
@EnvironmentObject private var currentAccount: CurrentAccount
@EnvironmentObject private var curretnInstance: CurrentInstance
@EnvironmentObject private var preferences: UserPreferences
@EnvironmentObject private var theme: Theme
@EnvironmentObject private var client: Client
@EnvironmentObject private var routerPath: RouterPath
@StateObject private var viewModel: AccountDetailViewModel
@State private var scrollOffset: CGFloat = 0
@State private var isFieldsSheetDisplayed: Bool = false
@State private var isCurrentUser: Bool = false
@State private var isCreateListAlertPresented: Bool = false
@State private var createListTitle: String = ""
@State private var isEditingAccount: Bool = false
@State private var isEditingFilters: Bool = false
/// When coming from a URL like a mention tap in a status.
public init(accountId: String) {
_viewModel = StateObject(wrappedValue: .init(accountId: accountId))
}
/// When the account is already fetched by the parent caller.
public init(account: Account) {
_viewModel = StateObject(wrappedValue: .init(account: account))
}
public var body: some View {
ScrollViewReader { proxy in
ScrollViewOffsetReader { offset in
self.scrollOffset = offset
} content: {
LazyVStack(alignment: .leading) {
makeHeaderView(proxy: proxy)
familiarFollowers
.offset(y: -36)
featuredTagsView
.offset(y: -36)
Group {
Picker("", selection: $viewModel.selectedTab) {
ForEach(isCurrentUser ? AccountDetailViewModel.Tab.currentAccountTabs : AccountDetailViewModel.Tab.accountTabs,
id: \.self) { tab in
Image(systemName: tab.iconName)
.tag(tab)
}
}
.pickerStyle(.segmented)
.padding(.horizontal, .layoutPadding)
.offset(y: -20)
}
.id("status")
switch viewModel.tabState {
case .statuses:
if viewModel.selectedTab == .statuses {
pinnedPostsView
}
StatusesListView(fetcher: viewModel, isEmbdedInList: false)
case .followedTags:
tagsListView
case .lists:
listsListView
}
}
}
.scrollContentBackground(.hidden)
.background(theme.primaryBackgroundColor)
}
.onAppear {
guard reasons != .placeholder else { return }
isCurrentUser = currentAccount.account?.id == viewModel.accountId
viewModel.isCurrentUser = isCurrentUser
viewModel.client = client
Task {
await withTaskGroup(of: Void.self) { group in
group.addTask { await viewModel.fetchAccount() }
group.addTask {
if await viewModel.statuses.isEmpty {
await viewModel.fetchStatuses()
}
}
if !viewModel.isCurrentUser {
group.addTask { await viewModel.fetchFamilliarFollowers() }
}
}
}
}
.refreshable {
Task {
await viewModel.fetchAccount()
await viewModel.fetchStatuses()
}
}
.onChange(of: watcher.latestEvent?.id) { _ in
if let latestEvent = watcher.latestEvent,
viewModel.accountId == currentAccount.account?.id
{
viewModel.handleEvent(event: latestEvent, currentAccount: currentAccount)
}
}
.onChange(of: isEditingAccount, perform: { isEditing in
if !isEditing {
Task {
await viewModel.fetchAccount()
await preferences.refreshServerPreferences()
}
}
})
.sheet(isPresented: $isEditingAccount, content: {
EditAccountView()
})
.sheet(isPresented: $isEditingFilters, content: {
FiltersListView()
})
.edgesIgnoringSafeArea(.top)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
toolbarContent
}
}
@ViewBuilder
private func makeHeaderView(proxy: ScrollViewProxy?) -> some View {
switch viewModel.accountState {
case .loading:
AccountDetailHeaderView(viewModel: viewModel,
account: .placeholder(),
scrollViewProxy: proxy,
scrollOffset: $scrollOffset)
.redacted(reason: .placeholder)
case let .data(account):
AccountDetailHeaderView(viewModel: viewModel,
account: account,
scrollViewProxy: proxy,
scrollOffset: $scrollOffset)
case let .error(error):
Text("Error: \(error.localizedDescription)")
}
}
@ViewBuilder
private var featuredTagsView: some View {
if !viewModel.featuredTags.isEmpty || !viewModel.fields.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 4) {
if !viewModel.fields.isEmpty {
Button {
isFieldsSheetDisplayed.toggle()
} label: {
VStack(alignment: .leading, spacing: 0) {
Text("account.detail.about")
.font(.scaledCallout)
Text("account.detail.n-fields \(viewModel.fields.count)")
.font(.caption2)
}
}
.buttonStyle(.bordered)
.sheet(isPresented: $isFieldsSheetDisplayed) {
fieldSheetView
}
}
if !viewModel.featuredTags.isEmpty {
ForEach(viewModel.featuredTags) { tag in
Button {
routerPath.navigate(to: .hashTag(tag: tag.name, account: viewModel.accountId))
} label: {
VStack(alignment: .leading, spacing: 0) {
Text("#\(tag.name)")
.font(.scaledCallout)
Text("account.detail.featured-tags-n-posts \(tag.statusesCountInt)")
.font(.caption2)
}
}.buttonStyle(.bordered)
}
}
}
.padding(.leading, .layoutPadding)
}
}
}
@ViewBuilder
private var familiarFollowers: some View {
if !viewModel.familiarFollowers.isEmpty {
VStack(alignment: .leading, spacing: 2) {
Text("account.detail.familiar-followers")
.font(.scaledHeadline)
.padding(.leading, .layoutPadding)
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 0) {
ForEach(viewModel.familiarFollowers) { account in
AvatarView(url: account.avatar, size: .badge)
.onTapGesture {
routerPath.navigate(to: .accountDetailWithAccount(account: account))
}
.padding(.leading, -4)
}
}
.padding(.leading, .layoutPadding + 4)
}
}
.padding(.top, 2)
.padding(.bottom, 12)
}
}
private var fieldSheetView: some View {
NavigationStack {
List {
ForEach(viewModel.fields) { field in
VStack(alignment: .leading, spacing: 2) {
Text(field.name)
.font(.scaledHeadline)
HStack {
if field.verifiedAt != nil {
Image(systemName: "checkmark.seal")
.foregroundColor(Color.green.opacity(0.80))
}
EmojiTextApp(field.value, emojis: viewModel.account?.emojis ?? [])
.foregroundColor(theme.tintColor)
.environment(\.openURL, OpenURLAction { url in
UIApplication.shared.open(url)
return .handled
})
}
.font(.scaledBody)
}
.listRowBackground(field.verifiedAt != nil ? Color.green.opacity(0.15) : theme.primaryBackgroundColor)
}
}
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
.navigationTitle("account.detail.about")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
isFieldsSheetDisplayed = false
} label: {
Image(systemName: "xmark")
.imageScale(.small)
.font(.body.weight(.semibold))
.frame(width: 30, height: 30)
.background(theme.primaryBackgroundColor.opacity(0.5))
.clipShape(Circle())
}
.foregroundColor(theme.tintColor)
}
}
}
}
private var tagsListView: some View {
Group {
ForEach(currentAccount.sortedTags) { tag in
HStack {
TagRowView(tag: tag)
Spacer()
Image(systemName: "chevron.right")
}
.padding(.horizontal, .layoutPadding)
.padding(.vertical, 8)
}
}.task {
await currentAccount.fetchFollowedTags()
}
}
private var listsListView: some View {
Group {
ForEach(currentAccount.sortedLists) { list in
NavigationLink(value: RouterDestinations.list(list: list)) {
HStack {
Text(list.title)
Spacer()
Image(systemName: "chevron.right")
}
.padding(.vertical, 8)
.padding(.horizontal, .layoutPadding)
.font(.scaledHeadline)
.foregroundColor(theme.labelColor)
}
.contextMenu {
Button("account.list.delete", role: .destructive) {
Task {
await currentAccount.deleteList(list: list)
}
}
}
}
Button("account.list.create") {
isCreateListAlertPresented = true
}
.padding(.horizontal, .layoutPadding)
}
.task {
await currentAccount.fetchLists()
}
.alert("account.list.create", isPresented: $isCreateListAlertPresented) {
TextField("account.list.name", text: $createListTitle)
Button("action.cancel") {
isCreateListAlertPresented = false
createListTitle = ""
}
Button("account.list.create.confirm") {
guard !createListTitle.isEmpty else { return }
isCreateListAlertPresented = false
Task {
await currentAccount.createList(title: createListTitle)
createListTitle = ""
}
}
} message: {
Text("account.list.create.description")
}
}
@ViewBuilder
private var pinnedPostsView: some View {
if !viewModel.pinned.isEmpty {
ForEach(viewModel.pinned) { status in
VStack(alignment: .leading) {
Label("account.post.pinned", systemImage: "pin.fill")
.font(.scaledFootnote)
.foregroundColor(.gray)
.fontWeight(.semibold)
StatusRowView(viewModel: .init(status: status))
}
.padding(.horizontal, .layoutPadding)
Rectangle()
.frame(height: 12)
.foregroundColor(theme.secondaryBackgroundColor)
}
}
}
@ToolbarContentBuilder
private var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .principal) {
if scrollOffset < -170 {
switch viewModel.accountState {
case let .data(account):
EmojiTextApp(.init(stringValue: account.safeDisplayName), emojis: account.emojis)
.font(.scaledHeadline)
default:
EmptyView()
}
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Menu {
if let account = viewModel.account {
Section(account.acct) {
if !viewModel.isCurrentUser {
Button {
routerPath.presentedSheet = .mentionStatusEditor(account: account,
visibility: preferences.postVisibility)
} label: {
Label("account.action.mention", systemImage: "at")
}
Button {
routerPath.presentedSheet = .mentionStatusEditor(account: account, visibility: .direct)
} label: {
Label("account.action.message", systemImage: "tray.full")
}
Divider()
if viewModel.relationship?.blocking == true {
Button {
Task {
do {
viewModel.relationship = try await client.post(endpoint: Accounts.unblock(id: account.id))
} catch {
print("Error while unblocking: \(error.localizedDescription)")
}
}
} label: {
Label("account.action.unblock", systemImage: "person.crop.circle.badge.exclamationmark")
}
} else {
Button {
Task {
do {
viewModel.relationship = try await client.post(endpoint: Accounts.block(id: account.id))
} catch {
print("Error while blocking: \(error.localizedDescription)")
}
}
} label: {
Label("account.action.block", systemImage: "person.crop.circle.badge.xmark")
}
}
if viewModel.relationship?.muting == true {
Button {
Task {
do {
viewModel.relationship = try await client.post(endpoint: Accounts.unmute(id: account.id))
} catch {
print("Error while unmuting: \(error.localizedDescription)")
}
}
} label: {
Label("account.action.unmute", systemImage: "speaker")
}
} else {
Menu {
ForEach(MutingDurations.allCases, id: \.rawValue) { duration in
Button (duration.description) {
Task {
do {
viewModel.relationship = try await client.post(endpoint: Accounts.mute(id: account.id, json: MuteData(duration: duration.rawValue)))
} catch {
print("Error while muting: \(error.localizedDescription)")
}
}
}
}
} label: {
Label("account.action.mute", systemImage: "speaker.slash")
}
}
if let relationship = viewModel.relationship,
relationship.following
{
if relationship.notifying {
Button {
Task {
do {
viewModel.relationship = try await client.post(endpoint: Accounts.follow(id: account.id,
notify: false,
reblogs: relationship.showingReblogs))
} catch {
print("Error while disabling notifications: \(error.localizedDescription)")
}
}
} label: {
Label("account.action.notify-disable", systemImage: "bell.fill")
}
} else {
Button {
Task {
do {
viewModel.relationship = try await client.post(endpoint: Accounts.follow(id: account.id,
notify: true,
reblogs: relationship.showingReblogs))
} catch {
print("Error while enabling notifications: \(error.localizedDescription)")
}
}
} label: {
Label("account.action.notify-enable", systemImage: "bell")
}
}
if relationship.showingReblogs {
Button {
Task {
do {
viewModel.relationship = try await client.post(endpoint: Accounts.follow(id: account.id,
notify: relationship.notifying,
reblogs: false))
} catch {
print("Error while disabling reboosts: \(error.localizedDescription)")
}
}
} label: {
Label("account.action.reboosts-hide", systemImage: "arrow.left.arrow.right.circle.fill")
}
} else {
Button {
Task {
do {
viewModel.relationship = try await client.post(endpoint: Accounts.follow(id: account.id,
notify: relationship.notifying,
reblogs: true))
} catch {
print("Error while enabling reboosts: \(error.localizedDescription)")
}
}
} label: {
Label("account.action.reboosts-show", systemImage: "arrow.left.arrow.right.circle")
}
}
}
Divider()
}
if viewModel.relationship?.following == true {
Button {
routerPath.presentedSheet = .listAddAccount(account: account)
} label: {
Label("account.action.add-remove-list", systemImage: "list.bullet")
}
}
if let url = account.url {
ShareLink(item: url) {
Label("account.action.share", systemImage: "square.and.arrow.up")
}
Button { UIApplication.shared.open(url) } label: {
Label("status.action.view-in-browser", systemImage: "safari")
}
}
Divider()
if isCurrentUser {
Button {
isEditingAccount = true
} label: {
Label("account.action.edit-info", systemImage: "pencil")
}
if curretnInstance.isFiltersSupported {
Button {
isEditingFilters = true
} label: {
Label("account.action.edit-filters", systemImage: "line.3.horizontal.decrease.circle")
}
}
Button {
routerPath.presentedSheet = .accountPushNotficationsSettings
} label: {
Label("settings.push.navigation-title", systemImage: "bell")
}
}
}
}
} label: {
if scrollOffset < -5 {
Image(systemName: "ellipsis.circle")
} else {
Image(systemName: "ellipsis.circle.fill")
}
}
}
}
}
struct AccountDetailView_Previews: PreviewProvider {
static var previews: some View {
AccountDetailView(account: .placeholder())
}
}