Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions IceCubesApp/App/Router/AppRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ extension View {
pinnedFilters: .constant([]),
selectedTagGroup: .constant(nil),
canFilterTimeline: false)
case .collectionDetail(let collection):
CollectionDetailView(collection: collection)
case .linkTimeline(let url, let title):
TimelineView(
timeline: .constant(.link(url: url, title: title)),
Expand Down
23 changes: 23 additions & 0 deletions IceCubesApp/Resources/Localization/Localizable.xcstrings
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"sourceLanguage" : "en",
"strings" : {
Expand Down Expand Up @@ -12062,6 +12062,29 @@
}
}
},
"account.detail.collections-n-accounts %lld" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"variations" : {
"plural" : {
"one" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld account"
}
},
"other" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld accounts"
}
}
}
}
}
}
},
"account.detail.featured-tags-n-posts %lld" : {
"extractionState" : "manual",
"localizations" : {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import DesignSystem
import Models
import NetworkClient
import SwiftUI

@MainActor
public struct CollectionDetailView: View {
@Environment(Theme.self) private var theme
@Environment(MastodonClient.self) private var client

public let collection: AccountCollection

@State private var fetchedCollection: AccountCollection?
@State private var accounts: [Account] = []
@State private var isLoading: Bool = true
@State private var didError: Bool = false
@State private var isSensitiveContentRevealed: Bool = false

public init(collection: AccountCollection) {
self.collection = collection
}

public var body: some View {
List {
if isSensitiveContentHidden {
sensitiveContentSection
} else {
headerSection
accountsSection
}
}
.listStyle(.plain)
#if !os(visionOS)
.scrollContentBackground(.hidden)
.background(theme.primaryBackgroundColor)
#endif
.navigationTitle(displayedCollection.name)
.navigationBarTitleDisplayMode(.inline)
.task(id: isSensitiveContentRevealed) {
guard !isSensitiveContentHidden else { return }
await fetchAccounts()
}
}

private var displayedCollection: AccountCollection {
fetchedCollection ?? collection
}

private var isSensitiveContentHidden: Bool {
displayedCollection.sensitive && !isSensitiveContentRevealed
}

private var sensitiveContentSection: some View {
Section {
Button {
isSensitiveContentRevealed = true
} label: {
Label("status.media.sensitive.show", systemImage: "eye")
.frame(maxWidth: .infinity, alignment: .center)
}
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}

@ViewBuilder
private var headerSection: some View {
Section {
if !displayedCollection.description.asRawText.isEmpty {
Text(displayedCollection.description.asSafeMarkdownAttributedString)
.font(.scaledBody)
}
if let tag = displayedCollection.tag {
Text("#\(tag.name)")
.font(.scaledCallout)
.foregroundStyle(theme.tintColor)
}
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}

@ViewBuilder
private var accountsSection: some View {
Section {
if isLoading {
ProgressView()
.frame(maxWidth: .infinity, alignment: .center)
} else if didError {
Button("action.retry") {
Task {
await fetchAccounts()
}
}
.frame(maxWidth: .infinity, alignment: .center)
} else {
ForEach(accounts) { account in
AccountsListRow(viewModel: .init(account: account))
}
}
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}

private func fetchAccounts() async {
isLoading = true
didError = false
do {
let response: AccountCollectionResponse = try await client.get(
endpoint: Collections.collection(id: collection.id))
fetchedCollection = response.collection
let acceptedAccountIds = Set(response.collection.acceptedAccountIds)
accounts = response.accounts.filter { acceptedAccountIds.contains($0.id) }
isLoading = false
} catch {
isLoading = false
didError = true
}
}
}
19 changes: 19 additions & 0 deletions Packages/Account/Sources/Account/Detail/AccountDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public struct AccountDetailView: View {
@State private var viewState: AccountDetailState = .loading
@State private var relationship: Relationship?
@State private var familiarFollowers: [Account] = []
@State private var collections: [AccountCollection] = []
@State private var followButtonViewModel: FollowButtonViewModel?
@State private var translation: Translation?
@State private var isLoadingTranslation = false
Expand Down Expand Up @@ -66,6 +67,8 @@ public struct AccountDetailView: View {
.applyAccountDetailsRowStyle(theme: theme)
FeaturedTagsView(featuredTags: featuredTags, accountId: accountId)
.applyAccountDetailsRowStyle(theme: theme)
AccountCollectionsView(collections: collections)
.applyAccountDetailsRowStyle(theme: theme)
if let tabManager {
makeTabPicker(tabManager: tabManager)
.pickerStyle(.segmented)
Expand Down Expand Up @@ -123,6 +126,9 @@ public struct AccountDetailView: View {
await fetchFamiliarFollowers()
}
}
group.addTask {
await fetchCollections()
}
}
}
}
Expand All @@ -132,6 +138,7 @@ public struct AccountDetailView: View {
SoundEffectManager.shared.playSound(.pull)
HapticManager.shared.fireHaptic(.dataRefresh(intensity: 0.3))
await fetchAccount()
await fetchCollections()
if let tabManager {
await tabManager.refreshCurrentTab()
}
Expand All @@ -154,6 +161,7 @@ public struct AccountDetailView: View {
if oldValue == .accountEditInfo || newValue == .accountEditInfo {
Task {
await fetchAccount()
await fetchCollections()
await preferences.refreshServerPreferences()
}
}
Expand Down Expand Up @@ -338,6 +346,17 @@ extension AccountDetailView {
relationships: [])
}

private func fetchCollections() async {
if let apiVersion = currentInstance.instance?.apiVersions?.mastodon, apiVersion < 10 {
collections = []
return
}
guard let response: AccountCollectionsResponse = try? await client.get(
endpoint: Collections.accountCollections(id: accountId))
else { return }
collections = response.collections.filter(\.discoverable)
}

private func fetchFamiliarFollowers() async {
let familiarFollowersResponse: [FamiliarAccounts]? = try? await client.get(
endpoint: Accounts.familiarFollowers(withAccount: accountId))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import DesignSystem
import Env
import Models
import SwiftUI

struct AccountCollectionsView: View {
@Environment(RouterPath.self) private var routerPath

let collections: [AccountCollection]

var body: some View {
if !collections.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 4) {
ForEach(collections) { collection in
Button {
routerPath.navigate(to: .collectionDetail(collection: collection))
} label: {
VStack(alignment: .leading, spacing: 0) {
Label(collection.name, systemImage: "person.2.crop.square.stack")
.font(.scaledCallout)
Text("account.detail.collections-n-accounts \(collection.itemCount)")
.font(.caption2)
}
}.buttonStyle(.bordered)
}
}
.padding(.leading, .layoutPadding)
}
.padding(.top, 8)
}
}
}
1 change: 1 addition & 0 deletions Packages/Env/Sources/Env/Router.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public enum RouterDestination: Hashable {
case conversationDetail(conversation: Conversation)
case hashTag(tag: String, account: String?)
case list(list: Models.List)
case collectionDetail(collection: AccountCollection)
case followers(id: String)
case following(id: String)
case favoritedBy(id: String)
Expand Down
55 changes: 55 additions & 0 deletions Packages/Models/Sources/Models/AccountCollection.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import Foundation

/// A Mastodon Collection (4.6+): a public or unlisted, shareable curated list of accounts,
/// shown on the curator's profile. Named `AccountCollection` to avoid clashing with
/// `Swift.Collection`.
public struct AccountCollection: Codable, Identifiable, Equatable, Hashable {
public struct ShallowTag: Codable, Equatable, Hashable, Sendable {
public let name: String
public let url: String
}

public struct Item: Codable, Identifiable, Equatable, Hashable, Sendable {
public let id: String
public let accountId: String?
/// `pending`, `accepted`, `rejected`, or `revoked`.
public let state: String
public let createdAt: ServerDate
}

public let id: String
public let accountId: String
public let uri: String
public let url: URL?
public let name: String
public let description: HTMLString
public let language: String?
public let local: Bool
public let sensitive: Bool
public let discoverable: Bool
public let tag: ShallowTag?
public let createdAt: ServerDate
public let updatedAt: ServerDate
public let itemCount: Int
public let items: [Item]

public var acceptedAccountIds: [String] {
items.compactMap { item in
item.state == "accepted" ? item.accountId : nil
}
}
}

extension AccountCollection: Sendable {}

/// Response shape of `GET /api/v1/accounts/:id/collections`.
public struct AccountCollectionsResponse: Codable, Sendable {
public let collections: [AccountCollection]
}

/// Response shape of `GET /api/v1/collections/:id`, which also includes the
/// member accounts.
public struct AccountCollectionResponse: Codable, Sendable {
public let collection: AccountCollection
public let accounts: [Account]
}
Loading
Loading