IceCubesApp/Packages/Account/Sources/Account/AccountDetailView.swift

373 lines
12 KiB
Swift
Raw Normal View History

2023-01-17 10:36:01 +00:00
import DesignSystem
import EmojiText
import Env
2022-11-29 11:18:06 +00:00
import Models
import Network
2022-12-18 19:30:19 +00:00
import Shimmer
2023-01-17 10:36:01 +00:00
import Status
import SwiftUI
2022-11-29 11:18:06 +00:00
2023-01-17 10:36:01 +00:00
public struct AccountDetailView: View {
@Environment(\.openURL) private var openURL
2022-12-20 19:33:45 +00:00
@Environment(\.redactionReasons) private var reasons
2023-01-22 05:38:30 +00:00
@EnvironmentObject private var watcher: StreamWatcher
@EnvironmentObject private var currentAccount: CurrentAccount
@EnvironmentObject private var currentInstance: CurrentInstance
@EnvironmentObject private var preferences: UserPreferences
2022-12-24 14:09:17 +00:00
@EnvironmentObject private var theme: Theme
2022-11-29 11:18:06 +00:00
@EnvironmentObject private var client: Client
@EnvironmentObject private var routerPath: RouterPath
2023-01-17 10:36:01 +00:00
2022-11-29 11:18:06 +00:00
@StateObject private var viewModel: AccountDetailViewModel
2022-12-27 12:49:54 +00:00
@State private var isCurrentUser: Bool = false
@State private var isCreateListAlertPresented: Bool = false
@State private var createListTitle: String = ""
2023-01-27 19:36:40 +00:00
2023-01-10 07:24:05 +00:00
@State private var isEditingAccount: Bool = false
@State private var isEditingFilters: Bool = false
@State private var isEditingRelationshipNote: Bool = false
2023-01-17 10:36:01 +00:00
/// When coming from a URL like a mention tap in a status.
2022-11-29 11:18:06 +00:00
public init(accountId: String) {
_viewModel = StateObject(wrappedValue: .init(accountId: accountId))
}
2023-01-17 10:36:01 +00:00
/// When the account is already fetched by the parent caller.
2022-12-27 12:49:54 +00:00
public init(account: Account) {
_viewModel = StateObject(wrappedValue: .init(account: account))
2022-12-17 12:37:46 +00:00
}
2023-02-12 15:29:41 +00:00
2022-11-29 11:18:06 +00:00
public var body: some View {
2022-12-27 08:11:12 +00:00
ScrollViewReader { proxy in
2023-02-12 15:13:57 +00:00
List {
makeHeaderView(proxy: proxy)
.applyAccountDetailsRowStyle(theme: theme)
.padding(.bottom, -20)
familiarFollowers
.applyAccountDetailsRowStyle(theme: theme)
featuredTagsView
.applyAccountDetailsRowStyle(theme: theme)
2023-02-12 15:29:41 +00:00
2023-02-12 15:13:57 +00:00
Picker("", selection: $viewModel.selectedTab) {
ForEach(isCurrentUser ? AccountDetailViewModel.Tab.currentAccountTabs : AccountDetailViewModel.Tab.accountTabs,
id: \.self) { tab in
Image(systemName: tab.iconName)
.tag(tab)
}
2023-02-12 15:13:57 +00:00
}
.pickerStyle(.segmented)
.padding(.layoutPadding)
.applyAccountDetailsRowStyle(theme: theme)
2023-02-12 15:13:57 +00:00
.id("status")
2023-01-17 10:36:01 +00:00
2023-02-12 15:13:57 +00:00
switch viewModel.tabState {
case .statuses:
if viewModel.selectedTab == .statuses {
pinnedPostsView
2022-12-27 08:11:12 +00:00
}
StatusesListView(fetcher: viewModel,
client: client,
routerPath: routerPath)
2023-02-12 15:13:57 +00:00
case .followedTags:
tagsListView
case .lists:
listsListView
}
2022-11-29 11:18:06 +00:00
}
2023-02-12 17:14:34 +00:00
.environment(\.defaultMinListRowHeight, 1)
2023-02-12 15:13:57 +00:00
.listStyle(.plain)
2022-12-31 11:28:27 +00:00
.scrollContentBackground(.hidden)
.background(theme.primaryBackgroundColor)
2022-11-29 11:18:06 +00:00
}
.onAppear {
guard reasons != .placeholder else { return }
isCurrentUser = currentAccount.account?.id == viewModel.accountId
viewModel.isCurrentUser = isCurrentUser
viewModel.client = client
// Avoid capturing non-Sendable `self` just to access the view model.
let viewModel = self.viewModel
Task {
2023-01-17 20:08:05 +00:00
await withTaskGroup(of: Void.self) { group in
group.addTask { await viewModel.fetchAccount() }
group.addTask {
if await viewModel.statuses.isEmpty {
2023-02-25 09:10:27 +00:00
await viewModel.fetchNewestStatuses()
2023-01-17 20:08:05 +00:00
}
}
if !viewModel.isCurrentUser {
group.addTask { await viewModel.fetchFamilliarFollowers() }
}
}
2022-12-20 15:08:09 +00:00
}
}
.refreshable {
Task {
2023-02-28 17:55:08 +00:00
SoundEffectManager.shared.playSound(of: .pull)
HapticManager.shared.fireHaptic(of: .dataRefresh(intensity: 0.3))
2022-12-20 15:08:09 +00:00
await viewModel.fetchAccount()
2023-02-25 09:10:27 +00:00
await viewModel.fetchNewestStatuses()
2023-02-28 17:55:08 +00:00
HapticManager.shared.fireHaptic(of: .dataRefresh(intensity: 0.7))
SoundEffectManager.shared.playSound(of: .refresh)
2022-12-20 15:08:09 +00:00
}
2022-12-18 19:30:19 +00:00
}
2023-01-17 10:36:01 +00:00
.onChange(of: watcher.latestEvent?.id) { _ in
if let latestEvent = watcher.latestEvent,
2023-01-17 10:36:01 +00:00
viewModel.accountId == currentAccount.account?.id
{
viewModel.handleEvent(event: latestEvent, currentAccount: currentAccount)
}
}
2023-01-10 07:24:05 +00:00
.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()
})
.sheet(isPresented: $isEditingRelationshipNote, content: {
EditRelationshipNoteView(accountDetailViewModel: viewModel)
})
2022-12-20 08:37:07 +00:00
.edgesIgnoringSafeArea(.top)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
2023-01-04 17:37:58 +00:00
toolbarContent
}
2022-12-18 19:30:19 +00:00
}
2023-01-17 10:36:01 +00:00
2022-12-18 19:30:19 +00:00
@ViewBuilder
2022-12-27 08:11:12 +00:00
private func makeHeaderView(proxy: ScrollViewProxy?) -> some View {
switch viewModel.accountState {
2022-12-18 19:30:19 +00:00
case .loading:
AccountDetailHeaderView(viewModel: viewModel,
2022-12-20 16:11:12 +00:00
account: .placeholder(),
2023-02-12 15:13:57 +00:00
scrollViewProxy: proxy)
2022-12-18 19:30:19 +00:00
.redacted(reason: .placeholder)
case let .data(account):
AccountDetailHeaderView(viewModel: viewModel,
2022-12-20 16:11:12 +00:00
account: account,
2023-02-12 15:13:57 +00:00
scrollViewProxy: proxy)
2022-12-18 19:30:19 +00:00
case let .error(error):
Text("Error: \(error.localizedDescription)")
}
}
2023-01-22 05:38:30 +00:00
2022-12-21 19:26:38 +00:00
@ViewBuilder
private var featuredTagsView: some View {
2023-02-19 10:35:46 +00:00
if !viewModel.featuredTags.isEmpty {
2022-12-21 19:26:38 +00:00
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 4) {
2022-12-21 19:53:23 +00:00
if !viewModel.featuredTags.isEmpty {
ForEach(viewModel.featuredTags) { tag in
Button {
routerPath.navigate(to: .hashTag(tag: tag.name, account: viewModel.accountId))
2022-12-21 19:53:23 +00:00
} label: {
VStack(alignment: .leading, spacing: 0) {
Text("#\(tag.name)")
.font(.scaledCallout)
Text("account.detail.featured-tags-n-posts \(tag.statusesCountInt)")
2022-12-21 19:53:23 +00:00
.font(.caption2)
}
}.buttonStyle(.bordered)
}
2022-12-21 19:26:38 +00:00
}
}
.padding(.leading, .layoutPadding)
2022-12-21 19:26:38 +00:00
}
}
}
2023-01-17 10:36:01 +00:00
@ViewBuilder
private var familiarFollowers: some View {
if !viewModel.familiarFollowers.isEmpty {
2022-12-23 15:21:31 +00:00
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)
}
}
2023-01-17 10:36:01 +00:00
2023-01-04 17:37:58 +00:00
private var tagsListView: some View {
Group {
ForEach(currentAccount.sortedTags) { tag in
HStack {
TagRowView(tag: tag)
Spacer()
Image(systemName: "chevron.right")
}
2023-02-12 15:13:57 +00:00
.listRowBackground(theme.primaryBackgroundColor)
}
2023-01-04 17:37:58 +00:00
}.task {
await currentAccount.fetchFollowedTags()
}
}
2023-01-17 10:36:01 +00:00
private var listsListView: some View {
Group {
ForEach(currentAccount.sortedLists) { list in
2023-02-21 17:52:30 +00:00
NavigationLink(value: RouterDestination.list(list: list)) {
2023-02-12 15:13:57 +00:00
Text(list.title)
2023-02-12 15:29:41 +00:00
.font(.scaledHeadline)
.foregroundColor(theme.labelColor)
}
2023-02-12 15:13:57 +00:00
.listRowBackground(theme.primaryBackgroundColor)
.contextMenu {
Button("account.list.delete", role: .destructive) {
Task {
await currentAccount.deleteList(list: list)
}
}
}
}
Button("account.list.create") {
isCreateListAlertPresented = true
}
2023-02-12 15:13:57 +00:00
.tint(theme.tintColor)
.buttonStyle(.borderless)
.listRowBackground(theme.primaryBackgroundColor)
}
2023-01-04 17:37:58 +00:00
.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")
}
}
2023-01-17 10:36:01 +00:00
2023-01-03 17:22:08 +00:00
@ViewBuilder
private var pinnedPostsView: some View {
if !viewModel.pinned.isEmpty {
2023-02-12 17:14:34 +00:00
Label("account.post.pinned", systemImage: "pin.fill")
.font(.scaledFootnote)
.foregroundColor(.gray)
.fontWeight(.semibold)
.listRowInsets(.init(top: 0,
leading: 12,
bottom: 0,
trailing: .layoutPadding))
.listRowSeparator(.hidden)
.listRowBackground(theme.primaryBackgroundColor)
2023-01-03 17:22:08 +00:00
ForEach(viewModel.pinned) { status in
StatusRowView(viewModel: { .init(status: status, client: client, routerPath: routerPath) })
2023-01-03 17:22:08 +00:00
}
2023-02-12 17:14:34 +00:00
Rectangle()
.fill(theme.secondaryBackgroundColor)
.frame(height: 12)
.listRowInsets(.init())
.listRowSeparator(.hidden)
2023-01-03 17:22:08 +00:00
}
}
2023-01-17 10:36:01 +00:00
2023-01-04 17:37:58 +00:00
@ToolbarContentBuilder
private var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .navigationBarTrailing) {
Menu {
AccountDetailContextMenu(viewModel: viewModel)
if !viewModel.isCurrentUser {
Button {
isEditingRelationshipNote = true
} label: {
Label("account.relation.note.edit", systemImage: "pencil")
}
}
if isCurrentUser {
Button {
isEditingAccount = true
} label: {
Label("account.action.edit-info", systemImage: "pencil")
}
2023-01-17 10:36:01 +00:00
if currentInstance.isFiltersSupported {
Button {
isEditingFilters = true
} label: {
Label("account.action.edit-filters", systemImage: "line.3.horizontal.decrease.circle")
2023-01-04 17:37:58 +00:00
}
}
2023-01-17 10:36:01 +00:00
Button {
routerPath.presentedSheet = .accountPushNotficationsSettings
} label: {
Label("settings.push.navigation-title", systemImage: "bell")
2023-01-04 17:37:58 +00:00
}
2023-03-08 18:02:31 +00:00
if let account = viewModel.account {
Divider()
Button {
if let url = URL(string: "https://mastometrics.com/auth/login?username=\(account.acct)@\(client.server)&instance=\(client.server)&auto=true") {
openURL(url)
}
} label: {
Label("Mastometrics", systemImage: "chart.xyaxis.line")
}
Divider()
}
2023-03-08 18:02:31 +00:00
Button {
routerPath.presentedSheet = .settings
} label: {
Label("settings.title", systemImage: "gear")
}
2023-01-04 17:37:58 +00:00
}
} label: {
2023-02-12 15:13:57 +00:00
Image(systemName: "ellipsis.circle")
2023-01-04 17:37:58 +00:00
}
}
}
2022-12-17 12:37:46 +00:00
}
extension View {
func applyAccountDetailsRowStyle(theme: Theme) -> some View {
2023-02-21 06:23:42 +00:00
listRowInsets(.init())
.listRowSeparator(.hidden)
.listRowBackground(theme.primaryBackgroundColor)
}
}
2022-12-17 12:37:46 +00:00
struct AccountDetailView_Previews: PreviewProvider {
static var previews: some View {
AccountDetailView(account: .placeholder())
2022-11-29 11:18:06 +00:00
}
}